Update One-to-Many entities when using independent associations in a detached scenario

c# entity-framework entity-framework-6

Question

I'm a novice in using Entity Framework 6 and I got stuck trying to update an entity. I'm using the Code First approach and my model, reduced to the classes in question, looks like this:

public class Document
{
    public long Key { get; set; }
    public string Name { get; set; }
}

public class Batch
{
    public long Key { get; set; }
    public virtual List<Document> Documents { get; private set; }

    public void Add(Document document)
    {
        Documents.Add(document);
    }

    public void Remove(Document document)
    {
        Documents.RemoveAt(Documents.FindIndex(d => d.Key == document.Key));
    }
}

There is a one to many relationship between the Batch and Document, modeled through independent associations, so there is no explicit FK. An important particularity is that the Document class doesn't know about the Batch. This is a difference from other similar questions on Stackoverflow, like this one for instance. Entity Framework generates a null-able column Batch_Key in the Document table.

Here is the context class:

public class MyDbContext : DbContext
{
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Document>().HasKey(d => d.Key);
        modelBuilder.Entity<Batch>().HasKey(b => b.Key);

        modelBuilder.Entity<Batch>().HasMany(b => b.Documents).WithOptional().WillCascadeOnDelete(false);
        base.OnModelCreating(modelBuilder);
    }
}

I have a BatchRepository class with an Update() method that receives a Batch parameter and updates the batch in the database:

public class BatchRepository
{
    public void Update(Batch item)
    {
        using (var dbCtx = new MyDbContext(DBContextName))
        {
            batchesContext.Batches.Attach(item);
            batchesContext.Entry(item).State = EntityState.Modified;
            batchesContext.SaveChanges();
        }
    }
}

Here is a unittest for removing a document from a batch, that uses the Update() method:

[TestMethod]
public void CheckRemoveDocument()
{
    var batches = BatchRepository.FindAll().ToList();
    var batch = batches[0];
    var batchKey = batches[0].Key;
    var doc = batch.Documents[0];
    int batchNumberOfDocuments = batch.Documents.Count;

    batch.Remove(doc);
    BatchRepository.Update(batch);

    batch = BatchRepository.FindBy(batchKey);
    Assert.AreEqual(batchNumberOfDocuments - 1, batch.Documents.Count);
}

The test fails, the batchNumberOfDocuments is the same as before. If I implement a method to remove all the documents from a batch, like this:

public class BatchRepository
{
    public void RemoveDocuments(Batch item)
    {
        using (var dbCtx = new MyDbContext(DBContextName))
        {
            var existingBatch = batchesContext.Batches.Find(item.Key);
            batchesContext.Entry(existingBatch).Collection(b => b.Documents).Load();
            existingBatch.Documents.RemoveAll(d => true);
            batchesContext.SaveChanges();
        }
    }
}

the following test succeeds:

[TestMethod]
public void CheckRemoveAllDocuments()
{
    var batches = BatchRepository.FindAll().ToList();
    var batch = batches[0];
    var batchKey = batches[0].Key;
    BatchRepository.RemoveDocuments(batch);

    batch = BatchRepository.FindBy(batchKey);
    Assert.AreEqual(0, batch.Documents.Count);
}

So EF correctly tracks the relationship between the batch and the documents, setting the Batch_Key column to NULL for the documents removed. Why does this work in this scenario, but not in the one where I am updating? I assume this is because the Document entities linked to the batch are not attached to the context. The problem is that I don't know if the batch from the parameter of the Update() method has more documents, less documents, or an entirely different list of documents (also, it might be the case that something else than the Document list changed).

An implementation like this:

public class BatchRepository
{
    public void Update(Batch item)
    {
        using (var dbCtx = new MyDbContext(DBContextName))
        {
                var existingBatch = batchesContext.Batches.Find(item.Key);
                batchesContext.Entry(existingBatch).Collection(b => b.Documents).Load();

                foreach (var doc in item.Documents)
                {
                    batchesContext.Documents.Attach(doc);
                    batchesContext.Entry(doc).State = EntityState.Modified;
                }
                batchesContext.SaveChanges();
        }
    }
}

throws an exception:

System.InvalidOperationException: Attaching an entity of type 'Document' failed because another entity of the same type already has the same primary key value. This can happen when using the 'Attach' method or setting the state of an entity to 'Unchanged' or 'Modified' if any entities in the graph have conflicting key values. This may be because some entities are new and have not yet received database-generated key values. In this case use the 'Add' method or the 'Added' entity state to track the graph and then set the state of non-new entities to 'Unchanged' or 'Modified' as appropriate.

How should the Update() method be implemented to perform the update properly?

Thank you and sorry if the question sounds confusing, it's all due to the confusion in my mind related to EF.

1
1
5/23/2017 11:43:52 AM

Accepted Answer

It's clear that you know that when you disconect an entity from a context, it's status isn't tracked. So, as you're doing, you need to attach the entity back to the context and set the correct status.

The problem is that when the disconected entity is the root of a disconnected tree, you have to take care of the related entities. There are to possible ways to do so:

  • use Attach(): in this case you need to attach each entity individually, and set the status of each of the entities.
  • use Add(): in this case, the whole tree is attached at once, and all the entities get the Added status. So you need to set the status of each entity, except the new ones (becasue they already have the Added status).

Note that this behavior is desirable. For example if you have many-to-many relations, the new children could be completely new (added) or be pre-existant (unchanged). Or, you could have a parent entity of a one-to-many relation with removed children (deleted) and new entities (added). So, you always have to track an set the status of each entity.

2
5/30/2014 7:01:17 PM

Popular Answer

Following @JotaBe's advice, I'm tracking all the Document entities myself. Here is the code:

public class BatchRepository
{
    protected override void Update(Batch item)
    {
        using (var dbCtx = new MyDbContext(DBContextName))
        {
            var existingBatch = dbCtx.Batches.Find(item.Key);
            if (null != existingBatch)
            {
                // Load the documents for the existing batch
                dbCtx.Entry(existingBatch).Collection(b => b.Documents).Load();

                // Get the list of the documents that were removed from the existing batch
                var removedDocuments = existingBatch.Documents.Except(item.Documents).ToList();
                foreach (var doc in removedDocuments)
                {
                    // Remove the relationship between the documents and the batch
                    existingBatch.Documents.Remove(doc);
                }

                // Get the list of the newly added documents
                var addedDocuments = item.Documents.Except(existingBatch.Documents).ToList();
                foreach (var doc in addedDocuments)
                {
                    // The document exists in the repository, so we just attach it to the context
                    dbCtx.Documents.Attach(doc);

                    // Create the relation between the batch and document
                    existingBatch.Documents.Add(doc);
                }

                // Overwrite all property current values from modified batch' entity values, 
                // so that it will have all modified values and mark entity as modified.
                var batchEntry = dbCtx.Entry(existingBatch);
                batchEntry.CurrentValues.SetValues(item);

                dbCtx.SaveChanges();
            }
        }
    }
}


Related Questions





Related

Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow
Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow