Question

I'm using Entity Framework Code First with SQL Server, with a domain entity that is similar to this:

public class Item
{
  public ICollection<ItemLocation> ItemLocations { get; set; }
}

An item can be assigned to many locations throughout it's life, but only one can be active at any time, and we use this to get the actual location of the item:

public Location
{
  get
  {
    return ItemLocations.Where(x => x.IsActive).Select(x => x.Location).FirstOrDefault()
  }
}

This property works as expected if I load the entire item object:

var item = (from i in db.Items select i).FirstOrDefault();
Console.WriteLine(item.Location.Name);

However, I can't use this in my LINQ queries where I need to return an anonymous type, like this:

var items = from i in db.Items
            select new
                   {
                     ItemId = i.ItemId,
                     LocationName = i.Location.Name
                   };

Instead, I have to use the full query every time:

var items = from i in db.Items
            select new
                   {
                     ItemId = i.ItemId,
                     LocationName = i.ItemLocations.Where(x => x.IsActive).Select(x => x.Location).FirstOrDefault().Name
                   };

Ideally, I'd like to keep the logic for retrieving an item location in one place (like a property), rather than having to scatter these all over the place.

What is the best way to achieve this?

Était-ce utile?

La solution

So to start with, if we want to be able to combine this sub-query with another query then we need to define it as an Expression object, rather than as C# code. If it has already been compiled into IL code then the query provider cannot inspect it to look at what operations are performed and translate that into SQL code. Creating an Expression representing this operation is easy enough:

public static readonly Expression<Func<Item, ItemLocation>> LocationSelector =
    item => item.ItemLocations.Where(x => x.IsActive)
            .Select(x => x.Location)
            .FirstOrDefault();

Now that we have an expression to get a location from an item, we need to combine that with your custom expression for selecting out an anonymous object from an item, using this location. To do this we'll need a Combine method that can take one expression selecting an object into another object, as well as another expression that takes the original object, the result of the first expression, and computes a new result:

public static Expression<Func<TFirstParam, TResult>>
    Combine<TFirstParam, TIntermediate, TResult>(
    this Expression<Func<TFirstParam, TIntermediate>> first,
    Expression<Func<TFirstParam, TIntermediate, TResult>> second)
{
    var param = Expression.Parameter(typeof(TFirstParam), "param");

    var newFirst = first.Body.Replace(first.Parameters[0], param);
    var newSecond = second.Body.Replace(second.Parameters[0], param)
        .Replace(second.Parameters[1], newFirst);

    return Expression.Lambda<Func<TFirstParam, TResult>>(newSecond, param);
}

Internally, this simply replaces all instances of the parameter of the second expression with the body of the first; the rest of the code is simply ensuring a single parameter throughout and wrapping the result back into a new lambda. This code depends on the ability to replace all instances of one expression with another, which we can do using:

public static Expression Replace(this Expression expression,
    Expression searchEx, Expression replaceEx)
{
    return new ReplaceVisitor(searchEx, replaceEx).Visit(expression);
}
internal class ReplaceVisitor : ExpressionVisitor
{
    private readonly Expression from, to;
    public ReplaceVisitor(Expression from, Expression to)
    {
        this.from = from;
        this.to = to;
    }
    public override Expression Visit(Expression node)
    {
        return node == from ? to : base.Visit(node);
    }
}

Now that we have our Combine method all we need to do is call it:

db.Items.Select(Item.LocationSelector.Combine((item, location) => new
    {
        ItemId = item.ItemId,
        LocationName = location.Name
    }));

And voila.

If we wanted, we could print out the expression generated by the call to Combine instead of passing it to Select. Doing that, it prints out:

param => new <>f__AnonymousType3`2(ItemId = param.ItemId, 
    LocationName = param.ItemLocations.Where(x => x.IsActive)
    .Select(x => x.Location).FirstOrDefault().Name)

(whitespace added by myself)

That is exactly the query that you had specified out manually, however here we're re-using the existing sub-query without needing to type it out every single time.

Licencié sous: CC-BY-SA avec attribution
Non affilié à StackOverflow
scroll top