Search code examples
c#linqlinq-to-entitiesprojection

C# Linq to Entities method to project property to string


I am trying to refactor a line of code that is used all over the place. We are using EF6.1 and want to find the phone and email (as strings).

    public SiteLabelDto[] GetVendorSites(int vendorId)
    {
        return Context.Sites.Where(s => !s.IsDeleted && s.Vendor.Id == vendorId)
            .Select(s => new SiteLabelDto
            {
                Id = s.Id,
                Name = s.Name,                    
                Address = s.Address.StreetAddress + ", " + s.Address.Locality + ", " + s.Address.Region + ", " + s.Address.Postcode,
                Country = s.Address.Country.Name,
                Email = s.ContactPoints.FirstOrDefault(x => x.Type == ContactPointType.Email) != null ? s.ContactPoints.FirstOrDefault(x => x.Type == ContactPointType.Email).Value : "",
                Phone = s.ContactPoints.FirstOrDefault(x => x.Type == ContactPointType.Phone) != null ? s.ContactPoints.FirstOrDefault(x => x.Type == ContactPointType.Phone).Value : "",                    
            }).ToArray();
    }

The code above takes the list of contactpoints and tries to find the best fit for each type.

public class ContactPointEntity
{
    public int Id { get; set; }
    public string Value { get; set; }
    public ContactPointType Type { get; set; }
    public bool IsDefault { get; set; }
}

The method will be extended to try to return the IsDefault one first in the first as well.

My goal it to try and be able to put this into a method or extension so that I can say s.GetcontactPoint(ContactPointType.Email) or s.contactPoints.GetPoints(ContactPointType.Email) and return the string value, or return a contactpoint class if the string is not a possible situation.

The more I read about it, I think I will need to build some expression tree, not sure how yet.


Solution

  • You need to build an expression tree.

    First, since you need to introduce IsDefault condition, the expression could look like:

    s.ContactPoints
     .Where(x => x.Type == ContactPointType.Email && x.IsDefault)
     .Select(x => x.Value)
     .DefaultIfEmpty(string.Empty)
     .FirstOrDefault()
    

    Then, convert the contact point selector into an expression.

    private static Expression<Func<Site, string>> GetContactPoint(ParameterExpression siteParam, ParameterExpression cpeParam, ContactPointType type)
    {
        // Where.
        var typeCondition = Expression.Equal(Expression.Property(cpeParam, "Type"), Expression.Constant(type));
        var defaultCondition = Expression.Equal(Expression.Property(cpeParam, "IsDefault"), Expression.Constant(true));
        var condition = Expression.AndAlso(typeCondition, defaultCondition);
        var predicateExp = Expression.Lambda<Func<ContactPointEntity, bool>>(condition, cpeParam);
        var whereExp = Expression.Call(typeof(Enumerable), "Where", new[] { typeof(ContactPointEntity) }, Expression.Property(siteParam, "ContactPoints"), predicateExp);
    
        // Select.
        var valueExp = Expression.Lambda<Func<ContactPointEntity, string>>(Expression.Property(cpeParam, "Value"), cpeParam);
        var selectExp = Expression.Call(typeof(Enumerable), "Select", new[] { typeof(ContactPointEntity), typeof(string) }, whereExp, valueExp);
    
        // DefaultIfEmpty.
        var defaultIfEmptyExp = Expression.Call(typeof(Enumerable), "DefaultIfEmpty", new[] { typeof(string) }, selectExp, Expression.Constant(string.Empty));
    
        // FirstOrDefault.
        var firstOrDefaultExp = Expression.Call(typeof(Enumerable), "FirstOrDefault", new[] { typeof(string) }, defaultIfEmptyExp);
    
        var selector = Expression.Lambda<Func<Site, string>>(firstOrDefaultExp, siteParam);
        return selector;
    }
    

    And also create the site label dto selector.

    private static Expression<Func<Site, SiteLabelDto>> GetSite(ParameterExpression siteParam, ParameterExpression cpeParam)
    {
        var newExp = Expression.New(typeof(SiteLabelDto));
        var initExp = Expression.MemberInit(
            newExp,
            Expression.Bind(typeof(SiteLabelDto).GetProperty("Id"), Expression.Lambda<Func<Site, int>>(Expression.Property(siteParam, "Id"), siteParam).Body),
            Expression.Bind(typeof(SiteLabelDto).GetProperty("Name"), Expression.Lambda<Func<Site, string>>(Expression.Property(siteParam, "Name"), siteParam).Body),
            /* other basic information */
            Expression.Bind(typeof(SiteLabelDto).GetProperty("Email"), GetContactPoint(siteParam, cpeParam, ContactPointType.Email).Body),
            Expression.Bind(typeof(SiteLabelDto).GetProperty("Phone"), GetContactPoint(siteParam, cpeParam, ContactPointType.Phone).Body)
            /* other types */
        );
        var selector = Expression.Lambda<Func<Site, SiteLabelDto>>(initExp, siteParam);
        return selector;
    }
    

    Usage.

    var siteParam = Expression.Parameter(typeof(Site), "s");
    var cpeParam = Expression.Parameter(typeof(ContactPointEntity), "cpe");
    var selector = GetSite(siteParam, cpeParam);
    return Context.Sites
        .Where(s => !s.IsDeleted && s.Vendor.Id == vendorId)
        .Select(selector)
        .ToArray();
    

    PS:

    Probably some code above need to be refactored, this just gives the basic idea how to do it.

    update

    You can also create a wrapper class to contain the EF instance together with all contact points.

    public class ContactPointExt<T>
    {
        public T Instance { get; set; }
        public string Email { get; set; }
        public string Phone { get; set; }
    }
    

    Then change the GetSite into GetContactPoints to return the result as ContactPointExt<T>.

    private static Expression<Func<Site, ContactPointExt<T>>> GetContactPoints<T>(ParameterExpression siteParam, ParameterExpression cpeParam)
    {
        var type = typeof(ContactPointExt<T>);
        var newExp = Expression.New(type);
        var initExp = Expression.MemberInit(
            newExp,
            Expression.Bind(type.GetProperty("Instance"), siteParam),
            Expression.Bind(type.GetProperty("Email"), GetContactPoint(siteParam, cpeParam, ContactPointType.Email).Body),
            Expression.Bind(type.GetProperty("Phone"), GetContactPoint(siteParam, cpeParam, ContactPointType.Phone).Body)
        );
        var selector = Expression.Lambda<Func<Site, ContactPointExt<T>>>(initExp, siteParam);
        return selector;
    }
    

    The result of ContactPointExt<T> can be re-projected into SiteLabelDto with another Select.

    var siteParam = Expression.Parameter(typeof(Site), "s");
    var cpeParam = Expression.Parameter(typeof(ContactPointEntity), "cpe");
    var selector = GetContactPoints<Site>(siteParam, cpeParam);
    return Context.Sites
        .Where(s => !s.IsDeleted && s.Vendor.Id == vendorId)
        .Select(selector)
        .Select(s => new SiteLabelDto
        {
            Id = s.Instance.Id,
            Name = s.Instance.Name,
            Email = s.Email,
            Phone = s.Phone
        })
        .ToArray();
    

    EDIT from OP

    we created a wrapper method to make this a little bit simpler to reuse, putting it here just to show others:

        /// <summary>
        /// Wraps up a each of a query's objects in a ContactPointExt&lt;T&gt; object, providing the default contact point of each type.
        /// The original query object is accessed via the "Instance" property on each result.
        /// Assumes that the query object type has a property called ContactPoints - if different, supply the property name as the first argument.
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="query"></param>
        /// <param name="contactPointsPropertyName"></param>
        /// <returns></returns>
        public static IQueryable<ContactPointExt<T>> WithContactPointProcessing<T>(this IQueryable<T> query, string contactPointsPropertyName = "ContactPoints") where T : class
        {
            var siteParam = Expression.Parameter(typeof(T), "s");
            var cpeParam = Expression.Parameter(typeof(ContactPointEntity), "cpe");
            var selector = ContactPointHelpers.GetContactPoints<T>(siteParam, cpeParam, contactPointsPropertyName);
            return query.Select(selector);
        }