Saturday, May 23, 2009

Rails-like finders for NHibernate with C# 4.0

Now that VS2010 Beta 1 is out, I figured I'd take another look and try to actually do something with it. So I spiked a little proof of concept for what I mentioned about six months ago: Rails-like finders for NHibernate. In RoR's ActiveRecord you can write:

Person.find_by_name("pepe")

without really having a find_by_name method, thanks to Ruby's method_missing. Now, with a few lines of code, you can write this in C# 4.0, thanks to DynamicObject:

ISession session = ...
Person person = session.AsDynamic().GetPersonByName("pepe");

which behind the scenes will parse the method name "GetPersonByName" (using a Pascal-Casing convention) into a DetachedCriteria and execute it.

Note that I wrote "Person person = ..." instead of "var person = ...", that's because the result of GetPersonByName() is a dynamic so it has to be cast to its proper type to jump back to the strongly-typed world.

As I said before, this is only a proof of concept. It doesn't support finders that return collections, or operators (as in FindPersonByNameAndCity("Pepe", "Boulogne-sur-Mer")), etc. It would certainly be interesting to really implement this, but I wonder if people would use it, with IQueryable being more strongly-typed, way more flexible, and with better language integration.

Anyway, here's the code that makes this possible. Pay no attention to Castle ActiveRecord, I just used it because... well, geographical convenience, really.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text.RegularExpressions;
using Castle.ActiveRecord;
using Castle.ActiveRecord.Framework.Config;
using log4net.Config;
using NHibernate;
using NHibernate.Criterion;
using NUnit.Framework;

namespace NHDynamicTests {
    [TestFixture]
    public class DynamicTests {
        [Test]
        public void DynamicGetPersonByName() {
            using (ISession s = ActiveRecordMediator.GetSessionFactoryHolder().GetSessionFactory(typeof(object)).OpenSession()) {
                dynamic ds = s.AsDynamic();
                Person person = ds.GetPersonByName("pepe");
            }
        }

        [ActiveRecord]
        public class Person {
            [PrimaryKey]
            public int Id { get; set; }

            [Property]
            public string Name { get; set; }
        }

        [TestFixtureSetUp]
        public void FixtureSetup() {
            BasicConfigurator.Configure();
            var arConfig = new InPlaceConfigurationSource();
            var properties = new Dictionary<string, string> {
                {"connection.driver_class", "NHibernate.Driver.SQLite20Driver"},
                {"dialect", "NHibernate.Dialect.SQLiteDialect"},
                {"connection.provider", "NHibernate.Connection.DriverConnectionProvider"},
                {"connection.connection_string", "Data Source=test.db;Version=3;New=True;"},
            };

            arConfig.Add(typeof(ActiveRecordBase), properties);
            ActiveRecordStarter.ResetInitializationFlag();
            var arTypes = GetType().GetNestedTypes()
                .Where(t => t.GetCustomAttributes(typeof(ActiveRecordAttribute), true).Length > 0)
                .ToArray();
            ActiveRecordStarter.Initialize(arConfig, arTypes);
            ActiveRecordStarter.CreateSchema();
        }
    }

    public static class ISessionExtensions {
        public static dynamic AsDynamic(this ISession session) {
            return new DynamicSession(session);
        }
    }

    public class DynamicSession : DynamicObject {
        private readonly ISession session;

        public DynamicSession(ISession session) {
            this.session = session;
        }

        public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) {
            result = null;
            if (!binder.Name.StartsWith("Get"))
                return false;
            var tokens = Regex.Replace(binder.Name, "([A-Z])", " $1").Split(' ').Skip(1).ToArray();
            if (tokens.Length < 4 || args.Length < 1)
                return false; // some parameter is missing
            var typeName = tokens[1];
            var type = session.SessionFactory.GetAllClassMetadata()
                .Cast<DictionaryEntry>()
                .Select(e => e.Key).Cast<Type>()
                .FirstOrDefault(t => t.Name == typeName);
            if (type == null)
                throw new ApplicationException(string.Format("Type '{0}' is not mapped in NHibernate", typeName));
            var fieldName = tokens[3];
            var criteria = DetachedCriteria.For(type).Add(Restrictions.Eq(fieldName, args[0]));
            result = criteria.GetExecutableCriteria(session).UniqueResult();
            return true;
        }
    }
}

BTW: I just can't stand WPF's font rendering, it's so blurry! I don't care if it uses Ideal Width or Compatible Width or whatever you want to call it, it just looks very bad. Please vote for this issue so they fix it as soon as possible!

No comments: