EFCore连接更新的通用方法

6
我发现EFCore处理多对多关系的方式非常乏味,特别是更新连接的集合。这是一个频繁的需求:视图模型从前端传入了一个新的嵌套实体列表,我必须为每个嵌套实体编写一个方法,以确定需要删除什么,需要添加什么,然后进行删除和添加操作。有时一个实体有多个多对多关系,我必须为每个集合写出几乎相同的代码。
我认为可以使用通用方法来避免重复工作,但我很难想出具体实现方法。
让我先展示一下我目前的做法。
假设我们有以下模型:
public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }

    public virtual ICollection<PersonCar> PersonCars { get; set; } = new List<PersonCar>();
}

public class Car
{
    public int Id { get; set; }
    public string Manufacturer { get; set; }

    public virtual ICollection<PersonCar> PersonCars { get; set; } = new List<PersonCar>();
}

public class PersonCar
{
    public virtual Person Person { get; set; }
    public int PersonId { get; set; }
    public virtual Car Car { get; set; }
    public int CarId { get; set; }
}

使用流畅API定义的密钥
modelBuilder.Entity<PersonCar>().HasKey(t => new { t.PersonId, t.CarId });

我们添加一个新的人和相关车辆列表:

var person = new Person
{
    Name = "John",
    PersonCars = new List<PersonCar>
    {
        new PersonCar { CarId = 1 },
        new PersonCar { CarId = 2 },
        new PersonCar { CarId = 3 }
    }
};

db.Persons.Add(person);

db.SaveChanges();

John拥有汽车1,2,3。John在前端更新他的汽车,因此现在我获得了一个新的汽车id列表,所以我会像这样更新(实际代码可能会使用模型,并且可能会调用类似于这样的方法):

public static void UpdateCars(int personId, int[] newCars)
{
    using (var db = new PersonCarDbContext())
    {
        var person = db.Persons.Include(x => x.PersonCars).ThenInclude(x => x.Car).Single(x => x.Id == personId);

        var toRemove = person.PersonCars.Where(x => !newCars.Contains(x.CarId)).ToList();
        var toAdd = newCars.Where(c => !person.PersonCars.Any(x => x.CarId == c)).ToList();

        foreach (var pc in toRemove)
        {
            person.PersonCars.Remove(pc);
        }

        foreach (var carId in toAdd)
        {
            var pc = db.PersonCars.Add(new PersonCar { CarId = carId, PersonId = person.Id });
        }

        db.SaveChanges();
    }
}

我会先筛选需要删除的对象和需要添加的对象,然后执行相应的操作。这些步骤都非常简单,但在实际应用中,一个实体可能会有多个多对多的集合,例如标签、分类、选项等等。每个更新方法都基本相同,我最终会重复编写相同的代码好几次。例如,假设 Person 实体也有一个与 Category 实体的多对多关系,代码如下:

public static void UpdateCategory(int personId, int[] newCats)
{
    using (var db = new PersonCarDbContext())
    {
        var person = db.Persons.Include(x => x.PersonCategories).ThenInclude(x => x.Category).Single(x => x.Id == personId);

        var toRemove = person.PersonCategories.Where(x => !newCats.Contains(x.CategoryId)).ToList();
        var toAdd = newCats.Where(c => !person.PersonCategories.Any(x => x.CategoryId == c)).ToList();

        foreach (var pc in toRemove)
        {
            person.PersonCategories.Remove(pc);
        }

        foreach (var catId in toAdd)
        {
            var pc = db.PersonCategories.Add(new PersonCategory { CategoryId = catId, PersonId = person.Id });
        }

        db.SaveChanges();
    }
}

这是完全相同的代码,只是引用了不同的类型和属性。我最终会得到大量重复的代码。我做错了吗?还是这是使用通用方法的好例子?

我觉得这是使用通用方法的好地方,但我不太清楚如何做到。

它将需要实体类型、连接实体类型和外部实体类型,因此可能类似于:

public T UpdateJoinedEntity<T, TJoin, Touter>(PersonCarDbContext db, int entityId, int[] nestedids)
{
    //.. do same logic but with reflection?
}

该方法将运作并找出正确的属性,并进行必要的删除和添加。

这个可行吗?我不知道如何做到,但看起来似乎是可能的。


在许多情况下,仅替换所有连接记录是最简单的方法。 - Gert Arnold
这是一个很好的观点。 - Guerrilla
1个回答

9
< p >“一切都很简单”,但是因素分解并不那么简单,特别是考虑到不同的密钥类型、显式或阴影FK属性等,同时保持最小的方法参数。

这是我能想到的最好的因式分解方法,适用于具有2个显式int FK的链接(join)实体:

public static void UpdateLinks<TLink>(this DbSet<TLink> dbSet, 
    Expression<Func<TLink, int>> fromIdProperty, int fromId, 
    Expression<Func<TLink, int>> toIdProperty, int[] toIds)
    where TLink : class, new()
{
    // link => link.FromId == fromId
    var filter = Expression.Lambda<Func<TLink, bool>>(
        Expression.Equal(fromIdProperty.Body, Expression.Constant(fromId)),
        fromIdProperty.Parameters);
    var existingLinks = dbSet.Where(filter).ToList();

    var toIdFunc = toIdProperty.Compile();
    var deleteLinks = existingLinks
        .Where(link => !toIds.Contains(toIdFunc(link)));

    // toId => new TLink { FromId = fromId, ToId = toId }
    var toIdParam = Expression.Parameter(typeof(int), "toId");
    var createLink = Expression.Lambda<Func<int, TLink>>(
        Expression.MemberInit(
            Expression.New(typeof(TLink)),
            Expression.Bind(((MemberExpression)fromIdProperty.Body).Member, Expression.Constant(fromId)),
            Expression.Bind(((MemberExpression)toIdProperty.Body).Member, toIdParam)),
        toIdParam);
    var addLinks = toIds
        .Where(toId => !existingLinks.Any(link => toIdFunc(link) == toId))
        .Select(createLink.Compile());

    dbSet.RemoveRange(deleteLinks);
    dbSet.AddRange(addLinks);
}

所有需要的就是连接实体 DbSet,两个表示外键属性的表达式和所需的值。属性选择器表达式用于动态构建查询过滤器,并组合和编译一个函数对象来创建和初始化新的链接实体。
这段代码并不难,但需要了解 System.Linq.Expressions.Expression 方法知识。
与手写代码唯一的区别是:
Expression.Constant(fromId)

filter表达式内部会导致EF生成一个带有常量值而非参数的SQL查询,这将防止查询计划缓存。可以通过用以下内容替换上述内容来解决此问题:

Expression.Property(Expression.Constant(new { fromId }), "fromId")

说到这里,使用您的示例应该像这样:

public static void UpdateCars(int personId, int[] carIds)
{
    using (var db = new PersonCarDbContext())
    {
        db.PersonCars.UpdateLinks(pc => pc.PersonId, personId, pc => pc.CarId, carIds);
        db.SaveChanges();
    }
}

同时也可以反过来实现:

public static void UpdatePersons(int carId, int[] personIds)
{
    using (var db = new PersonCarDbContext())
    {
        db.PersonCars.UpdateLinks(pc => pc.CarId, carId, pc => pc.PersonId, personIds);
        db.SaveChanges();
    }
}

1
像往常一样出色(!=习惯了)!最好使用existingLinks而不必使用ToList(),这样它将被纳入添加和删除链接查询的SQL中。-- 仔细一想...那需要更多的重构。 - Gert Arnold
谢谢@Gert,我可以使用单个SQL查询删除链接,但我看不到添加链接的方法-因为查询根据IEnumerable执行N个子查询,最终构建类似于var toAdd = carIds.Except(db.Set<PersonCar>().Where(pc => pc.PersonId == personId && carIds.Contains(pc.CarId)).Select(pc => pc.CarId));var toDelete = db.Set<PersonCar>().Where(pc => pc.PersonId == personId && !carIds.Contains(pc.CarId));,但在我看来不值得。 - Ivan Stoev
不需要,可能只有在涉及非常大量的记录时才需要,但这通常不适用于交叉口。 - Gert Arnold

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接