Attribute-Based Property Aliases Using MongoDB and NoRM

Since MongoDB is a document database, collections don’t have an enforced schema. Each document in a collection needs to store the names of all of its properties, making the length of that name more significant. Using abbreviated property names helps cut down on the storage space needed for each document, but it’s not ideal to mirror those abbreviated names in your object model. To help solve this problem, the NoRM driver provides an easy mechanism for defining custom mappings between the document and your model. First I’ll go through the standard way of doing it, and then I’ll present a custom solution that helps cut down on the amount of code you have to write by using attributes.

The Model

First, let’s define a very simple model for trying this out. I have an interface that all Mongo model objects will implement, that makes sure they have a Mongo identifier property:

public interface IMongoObject  
{
    ObjectId Id { get; set; }
}

Then we have a Person class:

public class Person : IMongoObject  
{
    public ObjectId Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }
}

In MongoDB, I have a collection named Person, populated with a small amount of data:

> db.Person.find();
{ "_id" : ObjectId("4c64077b763a000000002f4c"), "fn" : "Greg", "ln" : "Shackles" }
{ "_id" : ObjectId("4c640792763a000000002f4d"), "fn" : "John", "ln" : "Doe" }
{ "_id" : ObjectId("4c640796763a000000002f4e"), "fn" : "Jane", "ln" : "Doe" }

You can see there that the documents use “fn” and “ln” instead of “FirstName” and “LastName”. Since the data access classes aren’t what I want to focus on, here are the (very basic) DAO classes we’ll use:

public abstract class DaoBase<T>  
    where T : IMongoObject
{
    private string _connectionString;

    public DaoBase(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected IQueryable<T> FindAll()
    {
        using (var mongo = Mongo.Create(_connectionString))
        {
            return mongo.GetCollection<T>().AsQueryable();
        }
    }
}
public class PersonDao : DaoBase<Person>, IPersonDao  
{
    public PersonDao(string connectionString)
        : base(connectionString)
    {
    }

    public IEnumerable<Person> FindAllPeople()
    {
        return FindAll();
    }

    public IEnumerable<Person> FindByLastName(string lastName)
    {
        return FindAll().Where(person => person.LastName == lastName);
    }
}

The Test

To make sure everything works, let’s set up a very simple page that will grab all the documents from the collection and display them as a list. Here’s the controller:

public class HomeController : Controller  
{
    [Inject]
    public IPersonDao PersonDao { get; set; }

    public ActionResult Index()
    {
        var people =
            PersonDao
                .FindAllPeople()
                .OrderBy(person => person.LastName)
                .ThenBy(person => person.FirstName);

        return View(people);
    }
}

I’m using Ninject to create and inject the PersonDao dependency into the controller. That’s outside the scope of this post, but you can dig into the sample if you want to see how that works. Here’s the relevant part of the view:

<ul>  
    <% foreach (var person in Model)
       { %>

        <li>
            <%: person.LastName %>, <%: person.FirstName %>
        </li>

    <% } %>
</ul>  

Got Maps?

If you tried to run that test page right now NoRM would throw an error at you saying that it couldn’t find properties on Person for “fn” and “ln”. If you don’t tell it otherwise, it assumes that it should map a document property to a model property with the same name. To customize this we can define a MongoConfigurationMap. If you’re familiar with FluentNHibernate, this should feel pretty familiar.

public class PersonMap : MongoConfigurationMap  
{
    public PersonMap()
    {
        For<Person>(config =>
        {
            config
                .ForProperty(person => person.FirstName)
                .UseAlias("fn");

            config
                .ForProperty(person => person.LastName)
                .UseAlias("ln");
        });
    }
}

Now NoRM will know to map a Person object, right? Almost. We just need to register that map so it knows to use it. In the constructor for PersonDao, let’s add this line to register it:

MongoConfiguration.Initialize(config => config.AddMap<PersonMap>());  

Now if we fire up the test page we’ll get a nice ordered list of names, fresh out of MongoDB.

Using Attributes

Defining a map is very simple, but I didn’t like the idea of defining a new map for every class, just to set some string aliases. To get around that, I decided to create an attribute to put on properties that specifies an alias name, and then a custom map that uses reflection to create a map based on the attributes.

The attribute is very simple, and just takes in a string:

public class MongoAliasAttribute : Attribute  
{
    public string AliasName { get; set; }

    public MongoAliasAttribute()
    {
    }

    public MongoAliasAttribute(string aliasName)
    {
        AliasName = aliasName;
    }
}

The heavy lifting all happens in the typed AttributeMap:

public class AttributeMap<T> : MongoConfigurationMap  
{
    public AttributeMap()
    {
        var mappings = getAliasMappings();

        For<T>(config =>
        {
            foreach (var mapping in mappings)
            {
                config
                    .ForProperty(getPropertyByNameExpression(mapping.Key))
                    .UseAlias(mapping.Value);
            }
        });
    }

    private Expression<Func<T, object>> getPropertyByNameExpression(string propertyName)
    {
        ParameterExpression param = Expression.Parameter(typeof(T));
        UnaryExpression body =
            Expression.Convert(
                Expression.Property(param, propertyName),
                typeof(object)
            );

        return Expression.Lambda<Func<T, object>>(body, param);
    }

    private Dictionary<string, string> getAliasMappings()
    {
        var mappings = new Dictionary<string, string>();

        foreach (var item in typeof(T).GetProperties())
        {
            MongoAliasAttribute aliasAttribute =
                item.GetCustomAttributes(false)
                    .OfType<MongoAliasAttribute>()
                    .FirstOrDefault();

            if (aliasAttribute != null)
            {
                mappings.Add(item.Name, aliasAttribute.AliasName);
            }
        }

        return mappings;
    }
}

First, the getAliasMappings() function reflects on the current type, and finds any properties decorated with MongoAliasAttribute, returning a dictionary that maps property names to their aliases. Then for each mapping, it tells NoRM to alias the properties. Since the ForProperty method expects a lambda expression for specifying which property to alias, the getPropertyByNameExpression() function builds that expression.

Now to register this map with NoRM. To make this easier to use for other classes, let’s move the registration into DaoBase and remove the MongoConfiguration.Initialize() call from the PersonDao constructor. Next we’ll define a Map() method in the base class, and call it from the constructor:

protected virtual void Map()  
{
    MongoConfiguration.Initialize(config => config.AddMap<AttributeMap<T>>());
}

Finally, we update the Person model object so that it specifies the aliases it wants to use:

public class Person : IMongoObject  
{
    public ObjectId Id { get; set; }

    [MongoAlias("fn")]
    public string FirstName { get; set; }

    [MongoAlias("ln")]
    public string LastName { get; set; }
}

If we fire up the test page now everything should work exactly like it did when using PersonMap, and now we don’t need a new mapping class every time we want to alias properties in a new collection. The next step here, which I’ll leave as an exercise for the reader, is to automatically search the assembly for classes that implement IMongoObject and register an AttributeMap for each of them.

comments powered by Disqus
Navigation