Filtering navigation properties in EF Code First

2019-01-26 08:13发布

问题:

I'm using Code First in EF. Let's say I have two entities:

public class Farm
{
    ....
    public virtual ICollection<Fruit> Fruits {get; set;}
}

public class Fruit
{
    ...

}

My DbContext is something like this:

public class MyDbContext : DbSet
{
    ....
    private DbSet<Farm> FarmSet{get; set;} 

    public IQueryable<Farm> Farms
    {
        get
        {
            return (from farm in FarmSet where farm.owner == myowner select farm);
        }
    }
}

I do this so that each user can only see his farms, and I don't have to call the Where on each query to the db.

Now, I want to filter all the fruits from one farm, I tried this (in Farm class):

from fruit in Fruits where fruit .... select fruit

but the query generated doesn't include the where clause, which is very important because I have dozens of thousands of rows and is not efficient to load them all and filter them when they're Objects.

I read that lazy loaded properties get filled the first time they're accessed but they read ALL the data, no filters can be applied UNLESS you do something like this:

from fruits in db.Fruits where fruit .... select fruit

But I can't do that, because Farm has no knowledge of DbContext (I don't think it should(?)) but also to me it just loses the whole purpose of using navigation properties if I have to work with all the data and not just the one that belongs to my Farm.

So,

  1. am I doing anything wrong / making wrong assumptions?
  2. Is there any way I can apply a filter to a navigation property that gets generated to the real query? (I'm working with a lot of data)

Thank you for reading!

回答1:

Unfortunately, I think any approach you might take would have to involve fiddling with the context, not just the entity. As you've seen, you can't filter a navigation property directly, since it's an ICollection<T> and not an IQueryable<T>, so it gets loaded all at once before you have a chance to apply any filters.

One thing you could possibly do is to create an unmapped property in your Farm entity to hold the filtered fruit list:

public class Farm
{
  ....
  public virtual ICollection<Fruit> Fruits { get; set; }

  [NotMapped]
  public IList<Fruit> FilteredFruits { get; set; }
}

And then, in your context/repository, add a method to load a Farm entity and populate FilteredFruits with the data you want:

public class MyDbContext : DbContext
{
  ....    

  public Farm LoadFarmById(int id)
  {
    Farm farm = this.Farms.Where(f => f.Id == id).Single(); // or whatever

    farm.FilteredFruits = this.Entry(farm)
                              .Collection(f => f.Fruits)
                              .Query()
                              .Where(....)
                              .ToList();

    return farm;
  }
}

...

var myFarm = myContext.LoadFarmById(1234);

That should populate myFarm.FilteredFruits with only the filtered collection, so you could use it the way you want within your entity. However, I haven't ever tried this approach myself, so there may be pitfalls I'm not thinking of. One major downside is that it would only work with Farms you load using that method, and not with any general LINQ queries you perform on the MyDbContext.Farms dataset.

All that said, I think the fact that you're trying to do this might be a sign that you're putting too much business logic into your entity class, when really it might belong better in a different layer. A lot of the time, it's better to treat entities basically as just receptacles for the contents of a database record, and leave all the filtering/processing to the repository or wherever your business/display logic lives. I'm not sure what kind of application you're working on, so I can't really offer any specific advice, but it's something to think about.

A very common approach if you decide to move things out the Farm entity is to use projection:

var results = (from farm in myContext.Farms
               where ....
               select new {
                 Farm = farm,
                 FilteredFruits = myContext.Fruits.Where(f => f.FarmId == farm.Id && ...).ToList()
               }).ToList();

...and then use the generated anonymous objects for whatever you want to do, rather than trying to add extra data to the Farm entities themselves.



回答2:

Just figured I'd add another solution to this having spent some time trying to append DDD principles to code first models. After searching around for some time I found a solution like the one below which works for me.

public class FruitFarmContext : DbContext
{
    public DbSet<Farm> Farms { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Farm>().HasMany(Farm.FruitsExpression).WithMany();
    }
}

public class Farm
{
    public int Id { get; set; }
    protected virtual ICollection<Fruit> Fruits  { get; set; }
    public static Expression<Func<Farm, ICollection<Fruit>>> FruitsExpression = x => x.Fruits;

    public IEnumerable<Fruit> FilteredFruits
    {
        get
        {
            //Apply any filter you want here on the fruits collection
            return Fruits.Where(x => true);
        }
    }
}

public class Fruit
{
    public int Id { get; set; }

}

The idea is that the farms fruit collection is not directly accessible but is instead exposed through a property that pre-filters it. The compromise here is the static expression that is required to be able to address the fruit collection when setting up mapping. I've started to use this approach on a number of projects where I want to control the access to an objects child collections.



回答3:

Lazy loading doesn't support filtering; use filtered explicit loading instead:

Farm farm = dbContext.Farms.Where(farm => farm.Owner == someOwner).Single();

dbContext.Entry(farm).Collection(farm => farm.Fruits).Query()
    .Where(fruit => fruit.IsRipe).Load();

The explicit loading approach requires two round trips to the database, one for the master and one for the detail. If it is important to stick to a single query, use a projection instead:

Farm farm = (
    from farm in dbContext.Farms
    where farm.Owner == someOwner
    select new {
        Farm = farm,
        Fruit = dbContext.Fruit.Where(fruit => fruit.IsRipe) // Causes Farm.Fruit to be eager loaded
    }).Single().Farm;

EF always binds navigation properties to their loaded entities. This means that farm.Fruit will contain the same filtered collection as the Fruit property in the anonymous type. (Just make sure you haven't loaded into the context any Fruit entities that should be filtered out, as described in Use Projections and a Repository to Fake a Filtered Eager Load.)