由于一个或多个外键属性是非空的,所以无法更改关系。

217
我在对实体进行GetById()操作后,将子实体的集合设置为来自MVC视图的新列表时,遇到了这个错误。

操作失败:由于一个或多个外键属性是非空的,因此无法更改关系。当对关系进行更改时,相关的外键属性被设置为null值。如果外键不支持null值,则必须定义一个新的关系,将外键属性分配给另一个非null值,或者删除无关的对象。

我不太理解这一行:

由于一个或多个外键属性是非空的,所以无法更改关系。

为什么我要更改两个实体之间的关系?它应该在整个应用程序的生命周期内保持不变。

异常发生的代码很简单,只是将修改后的子类分配给现有父类的集合。这样做可以处理删除子类、添加新子类和修改子类的情况。我本以为Entity Framework会处理这个问题。

这些代码可以简化为:

var thisParent = _repo.GetById(1);
thisParent.ChildItems = modifiedParent.ChildItems();
_repo.Save();

我在下面的文章中使用了解决方案#2找到了答案,基本上是为了引用父表而在子表中创建了一个主键(因此它有2个主键(父表的外键和子表的ID)。https://www.c-sharpcorner.com/UploadFile/ff2f08/entity-framework-error-the-relationship-could-not-be-chang/ - yougotiger
@jaffa,我在这里找到了答案:https://stackoverflow.com/questions/22858491/entity-framework-remove-object-with-foreign-key-preserving-parent。 - antonio
1
对我来说,解决方法很简单。我的数据库外键列是可空整数,但我的 EF 属性是整数。我将其更改为 int? 以匹配数据库,问题得到解决。 - redwards510
21个回答

174
您需要手动逐一删除旧的子项thisParent.ChildItems。Entity Framework不会为您执行此操作。最终,它无法决定您想要对旧的子项执行什么操作 - 是否要将它们丢弃还是保留并分配给其他父实体。您必须告诉Entity Framework您的决定。但是,这两个决定中的一个您必须做出,因为子实体不能在数据库中没有任何父引用的情况下独立存在(由于外键约束)。这基本上就是异常的含义。 编辑 如果可以添加、更新和删除子项,我会怎么做:
public void UpdateEntity(ParentItem parent)
{
    // Load original parent including the child item collection
    var originalParent = _dbContext.ParentItems
        .Where(p => p.ID == parent.ID)
        .Include(p => p.ChildItems)
        .SingleOrDefault();
    // We assume that the parent is still in the DB and don't check for null

    // Update scalar properties of parent,
    // can be omitted if we don't expect changes of the scalar properties
    var parentEntry = _dbContext.Entry(originalParent);
    parentEntry.CurrentValues.SetValues(parent);

    foreach (var childItem in parent.ChildItems)
    {
        var originalChildItem = originalParent.ChildItems
            .Where(c => c.ID == childItem.ID && c.ID != 0)
            .SingleOrDefault();
        // Is original child item with same ID in DB?
        if (originalChildItem != null)
        {
            // Yes -> Update scalar properties of child item
            var childEntry = _dbContext.Entry(originalChildItem);
            childEntry.CurrentValues.SetValues(childItem);
        }
        else
        {
            // No -> It's a new child item -> Insert
            childItem.ID = 0;
            originalParent.ChildItems.Add(childItem);
        }
    }

    // Don't consider the child items we have just added above.
    // (We need to make a copy of the list by using .ToList() because
    // _dbContext.ChildItems.Remove in this loop does not only delete
    // from the context but also from the child collection. Without making
    // the copy we would modify the collection we are just interating
    // through - which is forbidden and would lead to an exception.)
    foreach (var originalChildItem in
                 originalParent.ChildItems.Where(c => c.ID != 0).ToList())
    {
        // Are there child items in the DB which are NOT in the
        // new child item collection anymore?
        if (!parent.ChildItems.Any(c => c.ID == originalChildItem.ID))
            // Yes -> It's a deleted child item -> Delete
            _dbContext.ChildItems.Remove(originalChildItem);
    }

    _dbContext.SaveChanges();
}

注意:这个还没有经过测试。它假设子项集合的类型为ICollection。(我通常使用IList,所以代码会有所不同。)我也剥离了所有仓储抽象以使其简单明了。
我不知道这是否是一个好的解决方案,但我认为必须进行某种类似的艰苦工作来处理导航集合中的各种变化。如果有更简单的方法,我也很乐意看到它的出现。

2
在foreach中检索originalChildItem时,我会添加一个条件:...Where(c => c.ID == childItem.ID && c.ID != 0)。否则,如果childItem.ID == 0,则会返回新添加的子项。 - perfect_element
@perfect_element:你说得完全正确。那是一个将近三年的严重代码错误。谢谢! - Slauma
尝试自己让它工作浪费了一周的时间,感谢您的发布。EF 需要尝试自行处理这个问题。 - JsonStatham
@Slauma 你知道我们怎么通过使用仓储来实现这个吗?也就是说,不直接使用 _dbContext - Sampath
我和@jaffa一样,遇到了同样的问题和疑问。但是从这个答案的第一段就更清楚EF想要表达什么了。感谢解释和回答! - Aamol
显示剩余13条评论

142

你遇到此问题的原因是由于 组合(composition)聚合(aggregation) 之间的差异。

在组合中,当父对象被创建时,子对象也会被创建,并在其父对象被销毁时销毁。因此,它的生命周期受父对象控制。例如博客文章和其评论。如果删除了帖子,则应删除其评论。没有意义为不存在的帖子添加评论。订单和订单项同理。

在聚合中,子对象可以存在而不依赖于其父对象。如果父项被删除,子对象仍然可以存在,因为它可能会被添加到另一个父对象中。例如播放列表和其中的歌曲之间的关系。如果删除了播放列表,则不应删除歌曲。他们可以添加到另一个播放列表中。

Entity Framework区分聚合和组合关系的方式如下:

  • 对于组合:它期望子对象具有复合主键(ParentID,ChildID)。这是设计上的要求,因为子项的ID应该在其父项范围内。

  • 对于聚合:它期望子对象中的外键属性可为空。

因此,您遇到此问题的原因是由于在子表中设置主键的方式。它应该是复合的,但它不是。因此,Entity Framework将此关联视为聚合,这意味着当您删除或清除子对象时,它不会删除子记录。它将仅删除关联并将相应的外键列设置为NULL(因此这些子记录可以稍后与其他父项相关联)。由于您的列不允许NULL,所以会出现您提到的异常。

解决方案:

1- 如果您有充分理由不希望使用复合键,则需要显式删除子对象。这比之前建议的解决方案更简单:

context.Children.RemoveRange(parent.Children);

2- 另外,通过在您的子表上设置正确的主键,您的代码将更有意义:

2- 否则,通过在您的子表上设置适当的主键,您的代码将显得更加有意义:

parent.Children.Clear();

13
我发现这个解释非常有帮助。 - Booji Boy
9
组合和聚合的良好解释以及实体框架与此的关系。组合和聚合是面向对象编程中的两个核心概念,用于描述类之间的关系。组合表示一个类是由其他类组成的,而聚合则表示一个类包含其他类的实例。两者都描述了类之间的“整体-部分”关系,但它们之间有一些微妙的区别。在实体框架中,这两个概念也很重要。在定义实体之间的关系时,可以使用组合或聚合来表达它们之间的关系。例如,如果实体A包含多个实体B的实例,则可以将它们之间的关系描述为组合。另一方面,如果实体A包含对单个实体B的引用,则可以将它们之间的关系描述为聚合。了解组合和聚合的概念可以帮助您更好地设计和建模您的实体框架应用程序,并确保实体之间的关系得到正确处理。 - Chrysalis
#1 是修复问题所需的最少代码。谢谢! - ryanulit
实际上,有时使用复合键会给程序带来复杂性,最好只有一个标识列。https://medium.com/@pablodalloglio/7-reasons-not-to-use-composite-keys-1d5efd5ec2e5 - Mohammad Reza
哦,我的天啊!!!那是Mosh Hamedani!!! :D - KADEM Mohammed

80
这是一个非常大的问题。实际上,在您的代码中发生的情况如下:
  • 您从数据库加载Parent并获得一个已附加的实体
  • 您将其子集合替换为新的分离子集合
  • 您保存更改,但在此操作期间,所有子项都被视为已添加,因为EF在此之前不知道它们。因此,EF尝试将旧子项的外键设置为空,并插入所有新子项=>重复的行。
现在解决方案取决于您想要做什么以及您希望如何做?
如果您使用ASP.NET MVC,可以尝试使用UpdateModel或TryUpdateModel
如果您只想手动更新现有的子项,可以简单地执行以下操作:
foreach (var child in modifiedParent.ChildItems)
{
    context.Childs.Attach(child); 
    context.Entry(child).State = EntityState.Modified;
}

context.SaveChanges();

实际上,不需要附加(将状态设置为Modified也会附加实体),但我喜欢这样做,因为它使过程更显而易见。

如果你想修改现有的、删除现有的和插入新的子项,你必须做如下操作:

var parent = context.Parents.GetById(1); // Make sure that childs are loaded as well
foreach(var child in modifiedParent.ChildItems)
{
    var attachedChild = FindChild(parent, child.Id);
    if (attachedChild != null)
    {
        // Existing child - apply new values
        context.Entry(attachedChild).CurrentValues.SetValues(child);
    }
    else
    {
        // New child
        // Don't insert original object. It will attach whole detached graph
        parent.ChildItems.Add(child.Clone());
    }
}

// Now you must delete all entities present in parent.ChildItems but missing
// in modifiedParent.ChildItems
// ToList should make copy of the collection because we can't modify collection
// iterated by foreach
foreach(var child in parent.ChildItems.ToList())
{
    var detachedChild = FindChild(modifiedParent, child.Id);
    if (detachedChild == null)
    {
        parent.ChildItems.Remove(child);
        context.Childs.Remove(child); 
    }
}

context.SaveChanges();

1
但是你提到使用.Clone()的有趣评论很有意思。你是否考虑过ChildItem有其他子级导航属性的情况?但在这种情况下,我们不希望整个子图附加到上下文中吗?因为如果子项本身是新对象,我们期望所有子项都是新对象。(好吧,可能因模型而异,但让我们假设子项与父项一样“依赖”于子项。) - Slauma
可能需要“智能”克隆。 - Ladislav Mrnka
1
如果您不想在上下文中拥有一个子集合怎么办? http://stackoverflow.com/questions/20233994/do-i-need-to-create-a-dbset-for-every-table-so-that-i-can-persist-child-entitie - Kirsten
2
parent.ChildItems.Remove(child); context.Childs.Remove(child); 这个双重删除修复了我的问题,谢谢。为什么我们需要两个删除呢?如果只从parent.ChildItems中删除不够吗,因为子项只作为子项存在? - Fernando Torres
感谢您提供这个有用的代码,我的问题已经解决了。 - Sedat Kumcu
感谢您的见解。我的问题是,当我更新数据库项时,我使用了“Parent.List<itemType> = new List<itemType>()”语句,而原始列表仍然包含需要父级的项目。谢谢! - Boeykes

45

针对同一个错误,我发现这个答案更有帮助。看起来 EF 不喜欢 Remove 操作,而是更偏好 Delete 操作。

你可以像这样删除记录中关联的一组数据。

order.OrderDetails.ToList().ForEach(s => db.Entry(s).State = EntityState.Deleted);
在这个例子中,与一个订单关联的所有详细记录都被设置为删除状态。(在订单更新的一部分中,为重新添加更新后的详细信息做准备)

我相信这是正确的答案。 - Hossein
逻辑和简单的解决方案。 - sairfan
为我完美地解决了问题;迄今为止这里最好/最优雅的解决方案。 - taiji123

20
我不知道为什么其他两个答案如此受欢迎!我认为你的假设是正确的,ORM框架应该处理它 - 毕竟,这就是它承诺要提供的。否则,您的领域模型将被持久性问题破坏。如果您正确设置了级联设置,NHibernate可以轻松地管理。在Entity Framework中也是可能的,他们只是希望您在设置数据库模型时遵循更好的标准,特别是当他们需要推断应该执行哪些级联操作时:
您必须通过使用“标识关系”来定义父子关系
如果这样做,Entity Framework就知道子对象是由父对象标识的,因此必须是“级联删除孤儿”的情况。
除上述之外,您可能还需要(从NHibernate经验中)
thisParent.ChildItems.Clear();
thisParent.ChildItems.AddRange(modifiedParent.ChildItems);

不是完全替换列表,而是进行更新。

更新

@Slauma的评论提醒我,分离实体是整个问题的另一部分。为了解决这个问题,您可以采用使用自定义模型绑定器的方法,通过尝试从上下文中加载模型来构造您的模型。 此博客文章 显示了一个示例。


在这里设置为标识关系是没有帮助的,因为问题场景涉及到分离的实体(“来自MVC视图的我的新列表”)。您仍然需要从数据库加载原始子项,在该集合中查找基于分离集合的已删除项目,然后从数据库中删除。唯一的区别是,使用标识关系,您可以调用parent.ChildItems.Remove而不是_dbContext.ChildItems.Remove。仍然没有(EF <= 6)内置支持EF避免像其他答案中的代码那样冗长。 - Slauma
我理解你的观点。然而,我认为通过使用自定义模型绑定器从上下文中加载实体或返回新实例的方法可以使上述方法奏效。我会更新我的答案来建议这种解决方案。 - Andre Luus
是的,您可以使用模型绑定器,但现在您必须在模型绑定器中执行其他答案中的操作。这只是将问题从存储库/服务层移动到模型绑定器。至少我没有看到真正的简化。 - Slauma
简化是自动删除孤立实体。在模型绑定器中,你只需要一个泛型等效的 return context.Items.Find(id) ?? new Item() - Andre Luus
EF团队获得了良好的反馈,但是您提出的解决方案不幸地并没有解决EF领域中的任何问题。 - Chris Moschini
怎么样?我已经实现了这个功能,并且它正在生产软件中使用。 - Andre Luus

10

如果您正在使用AutoMapper和Entity Framework在同一类上,您可能会遇到这个问题。例如,如果您的类是

class A
{
    public ClassB ClassB { get; set; }
    public int ClassBId { get; set; }
}

AutoMapper.Map<A, A>(input, destination);

这将尝试复制两个属性。在此情况下,ClassBId是非空的。由于AutoMapper会复制 destination.ClassB = input.ClassB; 这将引起问题。

将您的 AutoMapper 设置为忽略 ClassB 属性。

 cfg.CreateMap<A, A>()
     .ForMember(m => m.ClassB, opt => opt.Ignore()); // We use the ClassBId

我遇到了与AutoMapper类似的问题,但这对我不起作用:(请参见https://dev59.com/L1gR5IYBdhLWcg3wV8Pe) - J86

7

我曾遇到同样的问题,但我知道在其他情况下它是可以正常工作的,所以我将问题简化为以下内容:

parent.OtherRelatedItems.Clear();  //this worked OK on SaveChanges() - items were being deleted from DB
parent.ProblematicItems.Clear();   // this was causing the mentioned exception on SaveChanges()
  • OtherRelatedItems 有一个复合主键(parentId + 一些本地列),工作正常。
  • ProblematicItems 有它们自己的单列主键,而 parentId 只是一个外键。这导致了 Clear() 后异常。

我所需要做的就是将 ParentId 设为复合主键的一部分,以表示子项不能没有父项存在。我使用了数据库优先模式,添加了主键并将 parentId 列标记为 EntityKey(因此,在数据库和 EF 中都必须进行更新 - 不确定只在 EF 中是否足够)。

我将 RequestId 设为主键的一部分 然后更新了 EF 模型,并将其他属性设置为实体键的一部分

一旦你考虑到这一点,EF 使用的这种优雅的区别就非常明显了,即子项“有意义”时是否需要父项存在(在这种情况下,除非将 ParentId 设置为其他特殊值,否则 Clear() 将不会删除它们并抛出异常),或者 - 就像在原始问题中一样 - 我们希望这些项在从父项中删除后被删除。


2
+1 很棒的回答,我今天遇到了这个问题,但无法解决。遵循您的解决方案(将ID和外键列作为组合主键),我的.Clear()操作终于起作用了。谢谢。 - Dalbir Singh
1
谢谢!我为此苦恼了3个小时。这是最短的解决方案。 - Alexander Brattsev
这似乎与我的问题完全相同。我对解决方案的问题在于,从数据库设计的角度来看,复合键并不完全正确。如果我要将类似于您的ParentId列添加到PK中,则还需要在其他列上添加“UNIQUE”约束,以确保它保持唯一并维护数据完整性。目前,PK约束正在执行此操作。 - Philip Stratford

4
这是因为子实体被标记为“修改”而不是“删除”。当执行parent.Remove(child)时,EF对子实体进行的修改只是将其引用设置为null。在异常发生后,你可以通过在Visual Studio的立即窗口中键入以下代码来检查子实体的EntityState,然后执行SaveChanges()
_context.ObjectStateManager.GetObjectStateEntries(System.Data.EntityState.Modified).ElementAt(X).Entity

X应该被替换为已删除的实体。

如果您无法访问ObjectContext以执行_context.ChildEntity.Remove(child),则可以通过将外键作为子表主键的一部分来解决此问题。

Parent
 ________________
| PK    IdParent |
|       Name     |
|________________|

Child
 ________________
| PK    IdChild  |
| PK,FK IdParent |
|       Name     |
|________________|

这种方式,如果你执行parent.Remove(child),EF会正确地将实体标记为已删除。


4
我刚遇到了同样的错误。我有两个表,它们之间存在父子关系,并在子表的表定义中配置了“on delete cascade”外键列。因此,当我通过SQL手动删除数据库中的父行时,它将自动删除子行。
然而,在EF中,这并没有起作用,出现了本线程描述的错误。原因是,在我的实体数据模型(edmx文件)中,父表和子表之间的关联属性不正确。End1 OnDelete选项被配置为none(在我的模型中,End1是具有多重性1的端点)。
我手动将End1 OnDelete选项更改为Cascade,然后它就可以工作了。我不知道为什么EF无法在我从数据库更新模型时检测到这一点(我有一个数据库优先模型)。
为了完整起见,这是我的删除代码的样子:
   public void Delete(int id)
    {
        MyType myObject = _context.MyTypes.Find(id);

        _context.MyTypes.Remove(myObject);
        _context.SaveChanges(); 
   }    

如果我没有定义级联删除,那么在删除父行之前,我就必须手动删除子行。

2
今天我遇到了这个问题,想分享一下我的解决方法。在我的情况下,解决方案是在从数据库中获取父项之前删除子项。
以前我是像下面的代码一样做的。然后我会得到与此问题中列出的相同错误。
var Parent = GetParent(parentId);
var children = Parent.Children;
foreach (var c in children )
{
     Context.Children.Remove(c);
}
Context.SaveChanges();

对我而言有效的方法是先获取子项,使用父项ID(外键),然后删除这些子项。接着,我可以从数据库中获取父项,此时它不应该再有任何子项,然后我可以添加新的子项。

var children = GetChildren(parentId);
foreach (var c in children )
{
     Context.Children.Remove(c);
}
Context.SaveChanges();

var Parent = GetParent(parentId);
Parent.Children = //assign new entities/items here

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