Tuesday, 10 February 2015

Automatic mapping for deep object graphs in Entity Framework



Using the great code sample from DevTrends article "Stop using AutoMapper in your source code", I have adjusted the code a bit to support not only up to two layers deep object graphs, but arbitrarily deepth. The limit is a design choice which can be easily increased into deeper levels. The code will automatic generate the mapping code using Expression Trees. Example:


.New DevTrends.QueryableExtensionsExample.StudentSummary(){
    FirstName = $src.FirstName,
    LastName = $src.LastName,
    TutorName = ($src.Tutor).Name,
    TutorAddressStreet = (($src.Tutor).Address).Street,
    TutorAddressMailboxNumber = ((($src.Tutor).Address).Mailbox).Number,
    CoursesCount = ($src.Courses).Count
}


Consider the automatic mapping of an entity in Entity Framework to a flattened model, where the entity is a relatively deep object graph. The tedious mapping code will be boring to write when the number properties grow. If we stick to a convention where we use CamelCasing to denote levels in the object graph, we can address the subproperties arbitrarily deep with this code.

   public class StudentSummary
    {

        public string FirstName { get; set; }

        public string LastName { get; set; }

        public string NotADatabaseColumn { get; set; }

        public string TutorName { get; set; }

        public string TutorAddressStreet { get; set;  }

        public string TutorAddressMailboxNumber { get; set; }

        public int CoursesCount { get; set; }

        public string FullName
        {
            get { return string.Format("{0} {1}", FirstName, LastName); }
        }

    }

Note here that for a given property such as TutorAddressMailboxNumber, the code will look for the property Tutor.Address.Mailbox.Number since the CamelCasing convention used here will check this. The implementation of the Project() method and the To() method on IQueryable<T> is shown next. The code uses Expression Trees to generate the required automatic mapping code. Note that the method Buildbinding method will run when the Expression.Lambda statement is executed, when debugging.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.RegularExpressions;

namespace DevTrends.QueryableExtensionsExample
{

    public static class QueryableExtensions
    {
        public static ProjectionExpression<TSource> Project<TSource>(this IQueryable<TSource> source)
        {
            return new ProjectionExpression<TSource>(source);
        }
    }

    public class ProjectionExpression<TSource>
    {
        private static readonly Dictionary<string, Expression> _expressionCache = new Dictionary<string, Expression>();

        private readonly IQueryable<TSource> _source;

        public ProjectionExpression(IQueryable<TSource> source)
        {
            _source = source;
        }

        public IQueryable<TDest> To<TDest>()
        {
          var queryExpression = GetCachedExpression<TDest>() ?? BuildExpression<TDest>();

            return _source.Select(queryExpression);
        }        

        private static Expression<Func<TSource, TDest>> GetCachedExpression<TDest>()
        {
            var key = GetCacheKey<TDest>();

            return _expressionCache.ContainsKey(key) ? _expressionCache[key] as Expression<Func<TSource, TDest>> : null;
        }

        private static Expression<Func<TSource, TDest>> BuildExpression<TDest>()
        {
            var sourceProperties = typeof(TSource).GetProperties();
            var destinationProperties = typeof(TDest).GetProperties().Where(dest => dest.CanWrite);
            var parameterExpression = Expression.Parameter(typeof(TSource), "src");
            
            var bindings = destinationProperties
                                .Select(destinationProperty => BuildBinding(parameterExpression, destinationProperty, sourceProperties))
                                .Where(binding => binding != null);

            var expression = Expression.Lambda<Func<TSource, TDest>>(Expression.MemberInit(Expression.New(typeof(TDest)), bindings), parameterExpression);

            var key = GetCacheKey<TDest>();

            _expressionCache.Add(key, expression);

            return expression;
        }        

        private static MemberAssignment BuildBinding(Expression parameterExpression, MemberInfo destinationProperty, IEnumerable<PropertyInfo> sourceProperties)
        {
            var sourceProperty = sourceProperties.FirstOrDefault(src => src.Name == destinationProperty.Name);

            if (sourceProperty != null)
            {
                return Expression.Bind(destinationProperty, Expression.Property(parameterExpression, sourceProperty));
            }

            var propertyNameComponents = SplitCamelCase(destinationProperty.Name);

            if (propertyNameComponents.Length >= 2)
            {
                sourceProperty = sourceProperties.FirstOrDefault(src => src.Name == propertyNameComponents[0]);
                if (sourceProperty == null)
                    return null; 

                var propertyPath = new List<PropertyInfo> { sourceProperty };
                TraversePropertyPath(propertyPath, propertyNameComponents, sourceProperty);

                if (propertyPath.Count != propertyNameComponents.Length)
                    return null; //must be able to identify the path 

                 MemberExpression compoundExpression = null;
                
                for (int i = 0; i < propertyPath.Count; i++)
                {
                    compoundExpression = i == 0 ? Expression.Property(parameterExpression, propertyPath[0]) : 
                        Expression.Property(compoundExpression, propertyPath[i]); 
                }

                return compoundExpression != null ? Expression.Bind(destinationProperty, compoundExpression) : null; 
            }

            return null;
        }

        private static List<PropertyInfo> TraversePropertyPath(List<PropertyInfo> propertyPath, string[] propertyNames, 
            PropertyInfo currentPropertyInfo, int currentDepth = 1)
        {
            if (currentDepth >= propertyNames.Count() || currentPropertyInfo == null)
                return propertyPath; //do not go deeper into the object graph

            PropertyInfo subPropertyInfo = currentPropertyInfo.PropertyType.GetProperties().FirstOrDefault(src => src.Name == propertyNames[currentDepth]); 
            if (subPropertyInfo == null)
                return null; //The property to look for was not found at a given depth 

            propertyPath.Add(subPropertyInfo);

            return TraversePropertyPath(propertyPath, propertyNames, subPropertyInfo, ++currentDepth);
        }

        private static string GetCacheKey<TDest>()
        {
            return string.Concat(typeof(TSource).FullName, typeof(TDest).FullName);
        }

        private static string[] SplitCamelCase(string input)
        {
            return Regex.Replace(input, "([A-Z])", " $1", RegexOptions.Compiled).Trim().Split(' ');
        }

    }    
}

The database is initialized with some sample data, the sample uses an Entity Framework CodeFirst database.

using System;
using System.Data.Entity;
using System.Linq;

namespace DevTrends.QueryableExtensionsExample
{
    class Program
    {
        static void Main()
        {
            Database.SetInitializer(new TestDataContextInitializer());

            using (var context = new StudentContext())
            {
                ExampleOne(context);

                ExampleTwo(context);                
            }

            Console.Write("\nPress a key to exit: ");
            Console.Read();
        }

        private static void ExampleOne(StudentContext context)
        {
            // This is the kind of projection code that we are replacing

            //var students = from s in context.Students
            //               select new StudentSummary
            //                          {
            //                              FirstName = s.FirstName,
            //                              LastName = s.LastName,
            //                              TutorName = s.Tutor.Name,
            //                              CoursesCount = s.Courses.Count
            //                          };


            // The line below performs exactly the same query as the code above.

            var students = context.Students.Project().To();

            Console.Write("Example One\n\n");

            // Uncomment this to see the generated SQL. The extension method produces the same SQL as the projection.
            //Console.WriteLine("SQL: \n\n{0}\n\n", students);

            foreach (var student in students)
            {
                Console.WriteLine("{0} is tutored by {1} in {2} subject(s).", student.FullName, student.TutorName, student.CoursesCount);
            }
        }

        private static void ExampleTwo(StudentContext context)
        {
            //var students = from s in context.Students
            //               select new AnotherStudentSummary()
            //               {
            //                   FirstName = s.FirstName,
            //                   LastName = s.LastName,
            //                   Courses = s.Courses
            //               };

            var students = context.Students.Project().To();

            Console.WriteLine("\nExample Two\n");
            
            //Console.WriteLine("SQL: \n\n{0}\n\n", students);

            foreach (var student in students)
            {
                var coursesString = string.Join(", ", student.Courses.Select(s => s.Description).ToArray());
                Console.WriteLine("{0} {1} takes the following courses: {2}", student.FirstName, student.LastName, coursesString);
            }
        }
    }        
}



using System;
using System.Data.Entity;
using System.Linq;

namespace DevTrends.QueryableExtensionsExample
{
    class Program
    {
        static void Main()
        {
            Database.SetInitializer(new TestDataContextInitializer());

            using (var context = new StudentContext())
            {
                ExampleOne(context);

                ExampleTwo(context);                
            }

            Console.Write("\nPress a key to exit: ");
            Console.Read();
        }

        private static void ExampleOne(StudentContext context)
        {
            // This is the kind of projection code that we are replacing

            //var students = from s in context.Students
            //               select new StudentSummary
            //                          {
            //                              FirstName = s.FirstName,
            //                              LastName = s.LastName,
            //                              TutorName = s.Tutor.Name,
            //                              CoursesCount = s.Courses.Count
            //                          };


            // The line below performs exactly the same query as the code above.

            var students = context.Students.Project().To<StudentSummary>();

            Console.Write("Example One\n\n");

            // Uncomment this to see the generated SQL. The extension method produces the same SQL as the projection.
            //Console.WriteLine("SQL: \n\n{0}\n\n", students);

            foreach (var student in students)
            {
                Console.WriteLine("{0} is tutored by {1} in {2} subject(s).", student.FullName, student.TutorName, student.CoursesCount);
            }
        }

        private static void ExampleTwo(StudentContext context)
        {
            //var students = from s in context.Students
            //               select new AnotherStudentSummary()
            //               {
            //                   FirstName = s.FirstName,
            //                   LastName = s.LastName,
            //                   Courses = s.Courses
            //               };

            var students = context.Students.Project().To<AnotherStudentSummary>();

            Console.WriteLine("\nExample Two\n");
            
            //Console.WriteLine("SQL: \n\n{0}\n\n", students);

            foreach (var student in students)
            {
                var coursesString = string.Join(", ", student.Courses.Select(s => s.Description).ToArray());
                Console.WriteLine("{0} {1} takes the following courses: {2}", student.FirstName, student.LastName, coursesString);
            }
        }
    }        
}


I have tested the code with a depth of four for the object graph, which will be sufficient in most cases. The maximum level is 10 just to prevent the recursion for giving a possible stack overflow, but the limit can be easily adjusted here. Using the code provided here, it will be easier to automatic map entity framework code into models. The code provided here should be used for retrieving data, such as selects. Note that we are short-circuiting the change tracking of Entity Framework here and must stick to a CamelCasing convention to get the automatic mapping from Entity to the flattened model, that must keep to the CamelCasing convention. .

Download the sample code here

Source code in Visual Studio 2012 solution

Example integration test (The ObjectContextManager.ScopedOpPlanDataContext object here is an example ObjectContext)


        [Test]
        [Category(TestCategories.IntegrationTest)]
        public void ProjectToPerformsExpected()
        {
            using (var context = ObjectContextManager.ScopedOpPlanDataContext)
            {
                var globalParameters = context.GlobalParameters.Project().To<GlobalParametersDataContract>().ToList();
                Assert.IsNotNull(globalParameters);
                CollectionAssert.IsNotEmpty(globalParameters);
            }
        }


2 comments:

  1. In case you are interested in generating cash from your websites with popunder advertisments, you can try one of the biggest companies: PropellerAds.

    ReplyDelete
  2. If you want your ex-girlfriend or ex-boyfriend to come crawling back to you on their knees (even if they're dating somebody else now) you need to watch this video
    right away...

    (VIDEO) Get your ex back with TEXT messages?

    ReplyDelete