由于一个或多个外键属性不可为空,因此无法更改关系

问题描述 投票:168回答:18

当我在一个实体上使用GetById()然后将子实体的集合设置为来自MVC视图的新列表时,我收到此错误。

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

我不太明白这一行:

由于一个或多个外键属性不可为空,因此无法更改关系。

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

发生异常的代码很简单,即将集合中已修改的子类分配给现有父类。这有望满足删除子类,添加新类和修改的需要。我原以为Entity Framework会处理这个问题。

代码行可以提炼为:

var thisParent = _repo.GetById(1);
thisParent.ChildItems = modifiedParent.ChildItems();
_repo.Save();
entity-framework entity-framework-4.1
18个回答
149
投票

您应该手动删除旧子项thisParent.ChildItems。实体框架不会为您做到这一点。它最终无法决定你想要对旧的子项目做什么 - 如果你想要扔掉它们或者你想保留它们并将它们分配给其他父实体。您必须告诉实体框架您的决定。但是,您必须做出这两个决定中的一个,因为子实体不能在没有引用数据库中的任何父项的情况下独自生活(由于外键约束)。这基本上就是异常所说的。

编辑

如果可以添加,更新和删除子项,我会怎么做:

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
投票

您必须手动清除ChildItems集合并将新项目添加到其中:

thisParent.ChildItems.Clear();
thisParent.ChildItems.AddRange(modifiedParent.ChildItems);

之后,您可以调用DeleteOrphans扩展方法,该方法将处理孤立实体(必须在DetectChanges和SaveChanges方法之间调用)。

public static class DbContextExtensions
{
    private static readonly ConcurrentDictionary< EntityType, ReadOnlyDictionary< string, NavigationProperty>> s_navPropMappings = new ConcurrentDictionary< EntityType, ReadOnlyDictionary< string, NavigationProperty>>();

    public static void DeleteOrphans( this DbContext source )
    {
        var context = ((IObjectContextAdapter)source).ObjectContext;
        foreach (var entry in context.ObjectStateManager.GetObjectStateEntries(EntityState.Modified))
        {
            var entityType = entry.EntitySet.ElementType as EntityType;
            if (entityType == null)
                continue;

            var navPropMap = s_navPropMappings.GetOrAdd(entityType, CreateNavigationPropertyMap);
            var props = entry.GetModifiedProperties().ToArray();
            foreach (var prop in props)
            {
                NavigationProperty navProp;
                if (!navPropMap.TryGetValue(prop, out navProp))
                    continue;

                var related = entry.RelationshipManager.GetRelatedEnd(navProp.RelationshipType.FullName, navProp.ToEndMember.Name);
                var enumerator = related.GetEnumerator();
                if (enumerator.MoveNext() && enumerator.Current != null)
                    continue;

                entry.Delete();
                break;
            }
        }
    }

    private static ReadOnlyDictionary<string, NavigationProperty> CreateNavigationPropertyMap( EntityType type )
    {
        var result = type.NavigationProperties
            .Where(v => v.FromEndMember.RelationshipMultiplicity == RelationshipMultiplicity.Many)
            .Where(v => v.ToEndMember.RelationshipMultiplicity == RelationshipMultiplicity.One || (v.ToEndMember.RelationshipMultiplicity == RelationshipMultiplicity.ZeroOrOne && v.FromEndMember.GetEntityType() == v.ToEndMember.GetEntityType()))
            .Select(v => new { NavigationProperty = v, DependentProperties = v.GetDependentProperties().Take(2).ToArray() })
            .Where(v => v.DependentProperties.Length == 1)
            .ToDictionary(v => v.DependentProperties[0].Name, v => v.NavigationProperty);

        return new ReadOnlyDictionary<string, NavigationProperty>(result);
    }
}

1
投票

我已经尝试过这些解决方案以及其他许多解决方案,但它们都没有完成。由于这是谷歌的第一个答案,我将在这里添加我的解决方案。

对我来说效果很好的方法是在提交过程中将关系从图片中删除,因此没有什么可以让EF搞砸了。我通过在DBContext中重新查找父对象并删除它来完成此操作。由于重新找到的对象的导航属性都为null,因此在提交期间将忽略子对象的关系。

var toDelete = db.Parents.Find(parentObject.ID);
db.Parents.Remove(toDelete);
db.SaveChanges();

请注意,这假设外键是使用ON DELETE CASCADE设置的,因此当删除父行时,数据库将清除子项。


1
投票

这种解决方案对我有用:

Parent original = db.Parent.SingleOrDefault<Parent>(t => t.ID == updated.ID);
db.Childs.RemoveRange(original.Childs);
updated.Childs.ToList().ForEach(c => original.Childs.Add(c));
db.Entry<Parent>(original).CurrentValues.SetValues(updated);

重要的是,这会删除所有记录并再次插入它们。但对于我的情况(少于10)这没关系。

我希望它有所帮助。


1
投票

我使用了Mosh's solution,但对我来说,如何在代码中首先正确实现组合键并不明显。

所以这是解决方案:

public class Holiday
{
    [Key, Column(Order = 0), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int HolidayId { get; set; }
    [Key, Column(Order = 1), ForeignKey("Location")]
    public LocationEnum LocationId { get; set; }

    public virtual Location Location { get; set; }

    public DateTime Date { get; set; }
    public string Name { get; set; }
}

0
投票

我在几个小时之前遇到了这个问题并尝试了一切,但在我的情况下,解决方案与上面列出的不同。

如果您从数据库中使用已检索的实体并尝试修改它的子项,则会发生错误,但如果您从数据库中获取实体的新副本,则不应存在任何问题。不要用这个:

 public void CheckUsersCount(CompanyProduct companyProduct) 
 {
     companyProduct.Name = "Test";
 }

用这个:

 public void CheckUsersCount(Guid companyProductId)
 {
      CompanyProduct companyProduct = CompanyProductManager.Get(companyProductId);
      companyProduct.Name = "Test";
 }

0
投票

出现此问题是因为我们尝试删除父表仍然存在子表数据。我们在级联删除的帮助下解决了这个问题。

在dbcontext类中的模型Create方法中。

 modelBuilder.Entity<Job>()
                .HasMany<JobSportsMapping>(C => C.JobSportsMappings)
                .WithRequired(C => C.Job)
                .HasForeignKey(C => C.JobId).WillCascadeOnDelete(true);
            modelBuilder.Entity<Sport>()
                .HasMany<JobSportsMapping>(C => C.JobSportsMappings)
                  .WithRequired(C => C.Sport)
                  .HasForeignKey(C => C.SportId).WillCascadeOnDelete(true);

之后,在我们的API调用中

var JobList = Context.Job                       
          .Include(x => x.JobSportsMappings)                                     .ToList();
Context.Job.RemoveRange(JobList);
Context.SaveChanges();

级联删除选项使用此简单代码删除父级以及父级相关子表。以这种简单的方式尝试。

删除用于删除数据库中记录列表的范围谢谢


0
投票

我也用Mosh's answer解决了我的问题,我认为PeterB's answer有点因为它使用枚举作为外键。请记住,添加此代码后,您需要添加新的迁移。

我还可以推荐此博客文章以获取其他解决方案:

http://www.kianryan.co.uk/2013/03/orphaned-child/

码:

public class Child
{
    [Key, Column(Order = 0), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    public string Heading { get; set; }
    //Add other properties here.

    [Key, Column(Order = 1)]
    public int ParentId { get; set; }

    public virtual Parent Parent { get; set; }
}

0
投票

使用Slauma的解决方案,我创建了一些通用函数来帮助更新子对象和子对象的集合。

所有持久对象都实现了这个接口

/// <summary>
/// Base interface for all persisted entries
/// </summary>
public interface IBase
{
    /// <summary>
    /// The Id
    /// </summary>
    int Id { get; set; }
}

有了这个,我在我的存储库中实现了这两个功能

    /// <summary>
    /// Check if orgEntry is set update it's values, otherwise add it
    /// </summary>
    /// <param name="set">The collection</param>
    /// <param name="entry">The entry</param>
    /// <param name="orgEntry">The original entry found in the database (can be <code>null</code> is this is a new entry)</param>
    /// <returns>The added or updated entry</returns>
    public T AddOrUpdateEntry<T>(DbSet<T> set, T entry, T orgEntry) where T : class, IBase
    {
        if (entry.Id == 0 || orgEntry == null)
        {
            entry.Id = 0;
            return set.Add(entry);
        }
        else
        {
            Context.Entry(orgEntry).CurrentValues.SetValues(entry);
            return orgEntry;
        }
    }

    /// <summary>
    /// check if each entry of the new list was in the orginal list, if found, update it, if not found add it
    /// all entries found in the orignal list that are not in the new list are removed
    /// </summary>
    /// <typeparam name="T">The type of entry</typeparam>
    /// <param name="set">The database set</param>
    /// <param name="newList">The new list</param>
    /// <param name="orgList">The original list</param>
    public void AddOrUpdateCollection<T>(DbSet<T> set, ICollection<T> newList, ICollection<T> orgList) where T : class, IBase
    {
        // attach or update all entries in the new list
        foreach (T entry in newList)
        {
            // Find out if we had the entry already in the list
            var orgEntry = orgList.SingleOrDefault(e => e.Id != 0 && e.Id == entry.Id);

            AddOrUpdateEntry(set, entry, orgEntry);
        }

        // Remove all entries from the original list that are no longer in the new list
        foreach (T orgEntry in orgList.Where(e => e.Id != 0).ToList())
        {
            if (!newList.Any(e => e.Id == orgEntry.Id))
            {
                set.Remove(orgEntry);
            }
        }
    }

要使用它,我会执行以下操作:

var originalParent = _dbContext.ParentItems
    .Where(p => p.Id == parent.Id)
    .Include(p => p.ChildItems)
    .Include(p => p.ChildItems2)
    .SingleOrDefault();

// Add the parent (including collections) to the context or update it's values (except the collections)
originalParent = AddOrUpdateEntry(_dbContext.ParentItems, parent, originalParent);

// Update each collection
AddOrUpdateCollection(_dbContext.ChildItems, parent.ChildItems, orgiginalParent.ChildItems);
AddOrUpdateCollection(_dbContext.ChildItems2, parent.ChildItems2, orgiginalParent.ChildItems2);

希望这可以帮助


EXTRA:您还可以创建单独的DbContext Extensions(或您自己的上下文接口)类:

public static void DbContextExtentions {
    /// <summary>
    /// Check if orgEntry is set update it's values, otherwise add it
    /// </summary>
    /// <param name="_dbContext">The context object</param>
    /// <param name="set">The collection</param>
    /// <param name="entry">The entry</param>
    /// <param name="orgEntry">The original entry found in the database (can be <code>null</code> is this is a new entry)</param>
    /// <returns>The added or updated entry</returns>
    public static T AddOrUpdateEntry<T>(this DbContext _dbContext, DbSet<T> set, T entry, T orgEntry) where T : class, IBase
    {
        if (entry.IsNew || orgEntry == null) // New or not found in context
        {
            entry.Id = 0;
            return set.Add(entry);
        }
        else
        {
            _dbContext.Entry(orgEntry).CurrentValues.SetValues(entry);
            return orgEntry;
        }
    }

    /// <summary>
    /// check if each entry of the new list was in the orginal list, if found, update it, if not found add it
    /// all entries found in the orignal list that are not in the new list are removed
    /// </summary>
    /// <typeparam name="T">The type of entry</typeparam>
    /// <param name="_dbContext">The context object</param>
    /// <param name="set">The database set</param>
    /// <param name="newList">The new list</param>
    /// <param name="orgList">The original list</param>
    public static void AddOrUpdateCollection<T>(this DbContext _dbContext, DbSet<T> set, ICollection<T> newList, ICollection<T> orgList) where T : class, IBase
    {
        // attach or update all entries in the new list
        foreach (T entry in newList)
        {
            // Find out if we had the entry already in the list
            var orgEntry = orgList.SingleOrDefault(e => e.Id != 0 && e.Id == entry.Id);

            AddOrUpdateEntry(_dbContext, set, entry, orgEntry);
        }

        // Remove all entries from the original list that are no longer in the new list
        foreach (T orgEntry in orgList.Where(e => e.Id != 0).ToList())
        {
            if (!newList.Any(e => e.Id == orgEntry.Id))
            {
                set.Remove(orgEntry);
            }
        }
    }
}

并使用它像:

var originalParent = _dbContext.ParentItems
    .Where(p => p.Id == parent.Id)
    .Include(p => p.ChildItems)
    .Include(p => p.ChildItems2)
    .SingleOrDefault();

// Add the parent (including collections) to the context or update it's values (except the collections)
originalParent = _dbContext.AddOrUpdateEntry(_dbContext.ParentItems, parent, originalParent);

// Update each collection
_dbContext.AddOrUpdateCollection(_dbContext.ChildItems, parent.ChildItems, orgiginalParent.ChildItems);
_dbContext.AddOrUpdateCollection(_dbContext.ChildItems2, parent.ChildItems2, orgiginalParent.ChildItems2);

0
投票

我在删除记录时遇到的问题比发生的问题还要严重,因为这个问题的解决方案是当你要删除你的记录而不是在删除标题/主记录之前遗漏了一些东西时你必须写入代码在header / Master之前删除它的细节我希望你的问题将得到解决。


95
投票

您面临这种情况的原因是由于构成和聚合之间的差异。

在组合中,子对象在创建父对象时创建,在其父对象被销毁时被销毁。因此它的生命周期由其父母控制。例如博客文章及其评论。如果删除帖子,则应删除其评论。对不存在的帖子发表评论是没有意义的。订单和订单商品也是如此。

在聚合中,子对象可以存在而与其父对象无关。如果父对象被销毁,则子对象仍然可以存在,因为它可能会在以后添加到其他父对象。例如:播放列表与该播放列表中的歌曲之间的关系。如果删除播放列表,则不应删除歌曲。它们可能会添加到不同的播放列表中。

实体框架区分聚合和组合关系的方式如下:

  • 对于组合:它期望子对象具有复合主键(ParentID,ChildID)。这是设计的,因为孩子的ID应该在他们父母的范围内。
  • 对于聚合:它期望子对象中的外键属性可以为空。

因此,您遇到此问题的原因是您在子表中设置主键的原因。它应该是复合的,但事实并非如此。因此,实体框架将此关联视为聚合,这意味着,当您删除或清除子对象时,它不会删除子记录。它只是删除关联并将相应的外键列设置为NULL(因此这些子记录以后可以与不同的父关联)。由于您的列不允许NULL,因此您将获得您提到的异常。

解决方案:

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

context.Children.RemoveRange(parent.Children);

2-否则,通过在子表上设置正确的主键,您的代码看起来会更有意义:

parent.Children.Clear();

68
投票

这是一个非常大的问题。您的代码中实际发生的是:

  • 您从数据库加载Parent并获取附加实体
  • 您将其子集合替换为新的独立子集合
  • 您保存更改但在此操作期间,所有孩子都被视为添加,因为EF直到这时才知道他们。因此EF尝试将null设置为旧子项的外键并插入所有新子项=>重复行。

现在解决方案真的取决于你想做什么以及你想怎么做?

如果您使用的是ASP.NET MVC,可以尝试使用UpdateModel or 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();

38
投票

我发现this的回答对同样的错误更有帮助。当您删除时,EF似乎不喜欢它,它更喜欢删除。

您可以删除附加到这样的记录的记录集合。

order.OrderDetails.ToList().ForEach(s => db.Entry(s).State = EntityState.Deleted);

在该示例中,附加到订单的所有详细记录都将其状态设置为“删除”。 (准备添加更新的详细信息,作为订单更新的一部分)


19
投票

我不知道为什么其他两个答案如此受欢迎!

我相信你认为ORM框架应该处理它是正确的 - 毕竟,这是它承诺提供的。否则,您的域模型会因持久性问题而损坏。如果正确设置级联设置,NHibernate会愉快地管理它。在实体框架中,它们也有可能,它们只是希望您在设置数据库模型时遵循更好的标准,特别是当他们必须推断应该执行的级联时:

你必须使用“define the parent - child relationship”正确地identifying relationship

如果这样做,Entity Framework知道子对象是由父对象标识的,因此它必须是“级联 - 删除 - 孤立”情况。

除了以上,你可能需要(来自NHibernate的经验)

thisParent.ChildItems.Clear();
thisParent.ChildItems.AddRange(modifiedParent.ChildItems);

而不是完全替换列表。

UPDATE

@Slauma的评论提醒我,分离的实体是整体问题的另一部分。要解决这个问题,您可以采用自定义模型绑定器的方法,通过尝试从上下文加载模型来构建模型。 This blog post展示了我的意思的一个例子。


9
投票

如果您在同一个类上使用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

4
投票

发生这种情况是因为子实体被标记为已修改而不是已删除。

当执行parent.Remove(child)时,EF对子实体的修改只是将对其父级的引用设置为null

在执行SaveChanges()之后,当异常发生时,您可以通过在Visual Studio的立即窗口中键入以下代码来检查子实体的EntityState:

_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将正确地将实体标记为已删除。


3
投票

我刚才有同样的错误。我有两个父子关系表,但我在子表的表定义中的外键列上配置了“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
投票

我今天遇到了这个问题,想分享我的解决方案。就我而言,解决方案是在从数据库获取Parent之前删除Child项。

以前我在下面的代码中这样做。然后我会得到这个问题中列出的相同错误。

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

对我有用的是,首先使用parentId(外键)获取子项,然后删除这些项。然后我可以从数据库中获取Parent,此时,它不应该有任何子项目,我可以添加新的子项目。

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
© www.soinside.com 2019 - 2024. All rights reserved.