如何设置具有外键的集合属性?

3

我有一个 Business 和一个 Category 模型。

每个 Business 通过一个公开的集合拥有许多 CategoriesCategory 不考虑 Business 实体)。

这是我的控制器操作:

[HttpPost]
[ValidateAntiForgeryToken]
private ActionResult Save(Business business)
{
  //Context is a lazy-loaded property that returns a reference to the DbContext
  //It's disposal is taken care of at the controller's Dispose override.
  foreach (var category in business.Categories)
   Context.Categories.Attach(category);

  if (business.BusinessId > 0)
   Context.Businesses.Attach(business);
  else
   Context.Businesses.Add(business);

   Context.SaveChanges();
   return RedirectToAction("Index");
}

现在有几个 `business.Categories` 的 `CategoryId` 被设置为现有的 `Category`(`Category` 的 `Title` 属性缺失)。
点击 `SaveChanges` 并重新从服务器加载 `Business` 后,这些 `Categories` 就不再存在了。
所以我的问题是,用给定的现有 `CategoryId` 数组设置 `Business.Categories` 的正确方法是什么?
然而,在创建新的 `Business` 时,调用 `SaveChanges` 时会抛出以下 `DbUpdateException` 异常:
“在保存不公开其关系的外键属性的实体时发生错误。由于无法将单个实体标识为异常源,因此 `EntityEntries` 属性将返回 null。通过在实体类型中公开外键属性,可以更轻松地处理保存时的异常。有关详细信息,请参见 InnerException。”
内部异常(`OptimisticConcurrencyException`):
“存储更新、插入或删除语句影响了意外数量的行(0)。自加载实体以来,可能已经修改或删除了实体。请刷新 ObjectStateManager 条目。”
更新:回答后,下面是更新代码:
var storeBusiness = IncludeChildren().SingleOrDefault(b => b.BusinessId == business.BusinessId);
var entry = Context.Entry(storeBusiness);
entry.CurrentValues.SetValues(business);
//storeBusiness.Categories.Clear();

foreach (var category in business.Categories)
{
  Context.Categories.Attach(category);
  storeBusiness.Categories.Add(category);
}

当调用SaveChanges时,我会得到以下DbUpdateException:

在保存不公开其关系属性的外键实体时发生错误。 EntityEntries属性将返回null,因为无法将单个实体标识为异常来源。通过在实体类型中公开外键属性,可以更轻松地处理保存时的异常。请参阅InnerException获取详细信息。

这是Business / Category模型的样子:
public class Business
{
  public int BusinessId { get; set; }

  [Required]
  [StringLength(64)]
  [Display(Name = "Company name")]
  public string CompanyName { get; set; }

  public virtual BusinessType BusinessType { get; set; }

  private ICollection<Category> _Categories;
  public virtual ICollection<Category> Categories
  {
    get
    {
      return _Categories ?? (_Categories = new HashSet<Category>());
    }
    set
    {
      _Categories = value;
    }
  }

  private ICollection<Branch> _Branches;
  public virtual ICollection<Branch> Branches
  {
    get
    {
      return _Branches ?? (_Branches = new HashSet<Branch>());
    }
    set
    {
      _Branches = value;
    }
  }
}

public class Category
{
  [Key]
  public int CategoryId { get; set; }

  [Unique]
  [Required]
  [MaxLength(32)]
  public string Title { get; set; }

  public string Description { get; set; }

  public int? ParentCategoryId { get; set; }
  [Display(Name = "Parent category")]
  [ForeignKey("ParentCategoryId")]
  public virtual Category Parent { get; set; }

  private ICollection<Category> _Children;
  public virtual ICollection<Category> Children
  {
    get
    {
      return _Children ?? (_Children = new HashSet<Category>());
    }
    set
    {
      _Children = value;
    }
  }
}

再次明确一下,我要把现有/新的企业附加到已经存在于数据库中并具有ID的类别中。

3个回答

5
我会将现有业务和新增业务这两种情况分开处理,因为您提到的两个问题都有不同的原因。
更新现有业务实体
这是您示例中的“if”情况(if (business.BusinessId > 0))。显然,在此处不会发生任何更改,并且不会将任何更改存储到数据库中,因为您只是连接了Category对象和Business实体,然后调用了SaveChanges。附加意味着将实体添加到状态为Unchanged的上下文中,对于处于该状态的实体,EF根本不会向数据库发送任何命令。
如果要更新一个已分离的对象图(在您的情况下是Business加上Category实体集合),通常会出现以下问题:与存储在数据库中的当前状态相比,某个集合项可能已从集合中删除,另一项可能已被添加,甚至可能已修改集合项的属性以及父实体Business已被修改。除非在对象图被分离时手动跟踪所有更改(即EF本身无法跟踪更改),否则您正确更新整个对象图的唯一机会就是将其与数据库中的当前状态进行比较,然后将对象放入正确的状态Added、Deleted和Modified(也许还有Unchanged状态)。
因此,程序的步骤是从数据库中加载Business及其当前Categories,然后将分离的图中的更改合并到加载(连接)的图中。它可能看起来像这样:
private ActionResult Save(Business business)
{
    if (business.BusinessId > 0) // = business exists
    {
        var businessInDb = Context.Businesses
            .Include(b => b.Categories)
            .Single(b => b.BusinessId == business.BusinessId);

        // Update parent properties (only the scalar properties)
        Context.Entry(businessInDb).CurrentValues.SetValues(business);

        // Delete relationship to category if the relationship exists in the DB
        // but has been removed in the UI
        foreach (var categoryInDb in businessInDb.Categories.ToList())
        {
            if (!business.Categories.Any(c =>
                c.CategoryId == categoryInDb.CategoryId))
                businessInDb.Categories.Remove(categoryInDb);
        }

        // Add relationship to category if the relationship doesn't exist
        // in the DB but has been added in the UI
        foreach (var category in business.Categories)
        {
            var categoryInDb = businessInDb.Categories.SingleOrDefault(c =>
                c.CategoryId == category.CategoryId)

            if (categoryInDb == null)
            {
                Context.Categories.Attach(category);
                businessInDb.Categories.Add(category);
            }
            // no else case here because I assume that categories couldn't have
            // have been modified in the UI, otherwise the else case would be:
            // else
            //   Context.Entry(categoryInDb).CurrentValues.SetValues(category);
        }
    }
    else
    {
        // see below
    }
    Context.SaveChanges();

    return RedirectToAction("Index");
}

添加新的业务实体

您添加新的 业务 以及其相关的 类别 的流程是正确的。只需将所有 类别 作为现有实体附加到上下文中,然后将新的 业务 添加到上下文中即可:

foreach (var category in business.Categories)
    Context.Categories.Attach(category);
Context.Businesses.Add(business);
Context.SaveChanges();

如果您所附加的所有类别都有一个在数据库中存在的键值,那么这应该可以无异常地工作。

您的异常意味着至少有一个类别具有无效的键值(即它在数据库中不存在)。也许它已经从数据库中删除了,或者因为从Web UI中没有正确地回传回来。

如果是独立关联——即在类别中没有FK属性BusinessId的关联——您确实会得到这个OptimisticConcurrencyException。(EF似乎认为另一个用户已经从数据库中删除了该类别。)如果是外键关联——即关联具有在类别中具有FK属性BusinessId的关联——则会收到有关违反外键约束的异常。

如果您想避免此异常,而且实际上它是因为另一个用户删除了类别而不是因为类别为空/ 0(使用一个隐藏的输入字段来修复这个问题),您最好通过CategoryIdFind)从数据库中加载类别,而不是附加它们,如果一个类别不再存在,则忽略它并从business.Categories集合中删除它(或重定向到错误页面以通知用户等等)。


谢谢您的回复。关于第一部分,调用ObjectStateEntrySetValues是否会更新内部属性(例如Categories)?我问这个问题是因为图形非常庞大!此外,这些类别永远不应在此页面上进行编辑。我只想用新的类别替换旧的类别,而不考虑旧的类别。 - Shimmy Weitzhandler
@Shimmy:不,SetValues 只更新 Business 的标量属性,而不是导航属性。你所说的“用新的类别替换旧的类别”是什么意思?我的理解是(对于更新情况):您加载一个业务和类别。然后用户可以将其他(但现有的)类别分配给业务(复选框?),或从业务中取消分配类别(取消复选框?)。这样对吗? - Slauma
是的,就是这样。我想将类别设置为当前复选框,我不在乎之前的内容,以前的类别应该被清除。 - Shimmy Weitzhandler
@Shimmy:不,它不支持你的情况。针对你的场景,需要使用上述代码或类似的代码。我知道这是一个很长的代码(如果你删除注释,它就会变短 :)),并且比它应该的要更复杂,但是由于EF缺乏对更新分离图的良好支持,所以这是EF的一个缺点。在这里请求改进(http://entityframework.codeplex.com/workitem/864),但仍不能期望在EF 6中看到它的改进。您还可以尝试Brent McKendrick在工作项中提供的库(我自己没有尝试过),声称支持此功能。 - Slauma
@Shimmy:内部异常仍然是“OptimisticConcurrencyException”吗?您是否在调试器中检查了“business.Categories”集合中的“CategoryID”值是否真正有效?“ParentCategoryId”呢?所有类别都为“null”吗?如果不是,它是否指向一个有效的类别? - Slauma
显示剩余8条评论

0
我在尝试设置一个超过其MaxLength属性的字段时遇到了这个异常。该字段还具有必填属性。我正在使用Oracle后端数据库进行工作。直到我增加长度,我才从底层数据库引擎获得了一个描述性错误消息,指出长度太长。我怀疑外键相关的错误消息含糊地意味着并非所有关系都可以更新,因为其中一个插入失败并且没有更新它们的键。

0

我也遇到了这个异常。

我的问题是ADO Entity Framework没有设置添加对象的主键,导致数据库中添加对象的外键也无法设置。

我通过确保数据库本身设置主键来解决了这个问题。如果您正在使用SQL Server,可以在CREATE TABLE语句中的列声明中添加IDENTITY关键字来实现。

希望这可以帮助到您。


在我的情况下,PK 是 100% 设置的。 - Shimmy Weitzhandler

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