sam
sam

Reputation: 330

How to handle a detached entity graph that has duplicate entities with the same PK in Entity Framework 6

For example I'm creating an invoice graph..

The graph is constructed client side with AnuglarJS and then sent as JSON to the backend. In the backend I recieve a seemingly perfect object created by some model binding magic in Web Api 2.

Next I load an Administration entity from the DbContext and add the detached invoice to it's collection navigation property:

Administration.Lines.Add(invoice);

When DetectChanges is triggered each entity in the graph gets an entry with state EntityState.Added.

From the graph I only want to add the entities Invoice and InvoiceLines because the rest of the graph is unchanged. To make this work I've adopted the approach to track the state locally e.g. with a BaseModel interface that implements a State property (as suggested in Programming Entity Framework: DbContext, Chapter 4).

This all works very nicely... until for example, when I add 2 lines with the same item. For illustration purposes, I believe the model binder creates the object from JSON somewhat like:

var invoice = new Invoice
{
    State = State.Added,
    Number = "SALE-001",
    Lines =
    {
        new InvoiceLine
        {
            State = State.Added,
            Price = 10,
            Quantity = 1,
            Item = new Item { Id = 1, Description = "Product" }
        },
        new InvoiceLine
        {
            State = State.Added,
            Price = 10,
            Quantity = 1,
            DiscountPercentage = 50,
            Item = new Item { Id = 1, Description = "Product" }
        },
    }
};

As you can see the items share the same PK but actually are different objects. So when DetectChanges is triggered I have 2 entries of the entity Item with the same PK and this all blows up when I try to convert the state Added to Unchanged with an InvalidOperationException:

Saving or accepting changes failed because more than one entity of type 'AutomapperWithEF.Data.Domain.Item' have the same primary key value. Ensure that explicitly set primary key values are unique. Ensure that database-generated primary keys are configured correctly in the database and in the Entity Framework model. Use the Entity Designer for Database First/Model First configuration. Use the 'HasDatabaseGeneratedOption" fluent API or 'DatabaseGeneratedAttribute' for Code First configuration.

Should EF not somehow prevent this from occurring? By means of equality comparison for the same type and PK?

EDIT:

The DbContext where ChangeTracker.Entries is the trigger:

public class AppContext : DbContext
{
    public DbSet<Administration> Administrations { get; set; }
    public DbSet<Invoice> Invoices { get; set; }
    public DbSet<InvoiceLine> InvoiceLines { get; set; }
    public DbSet<Item> Items { get; set; }

    public override int SaveChanges()
    {
        foreach (var entry in ChangeTracker.Entries<BaseModel>())
        {
            BaseModel baseModel = entry.Entity;
            entry.State = ConvertState(baseModel.State);
        }

        return base.SaveChanges();
    }

    public static EntityState ConvertState(State state)
    {
        switch (state)
        {
            case State.Added:
                return EntityState.Added;
            case State.Modified:
                return EntityState.Modified;
            case State.Deleted:
                return EntityState.Detached;
            default:
                return EntityState.Unchanged;
        }
    }
}

An example to show what I mean: https://github.com/svieillard/SampleEF

Upvotes: 1

Views: 1134

Answers (2)

CodeThug
CodeThug

Reputation: 3192

You are deserializing some JSON into an Invoice which has two Item objects with the same ID. You then Add() that Invoice to the context then call SaveChanges().

When you do this, EF sees that you have the Item in there twice and throws an exception.

And even if you only had 1 item in there, EF would try to update the item based on the data in the JSON. You probably don't want that.

So we're going to remove the Items from the context.

Solution #1: Remove the items from the objects in memory and use just the ItemId instead

foreach (var line in invoice.Lines)
{
    if (line != null && line.Item != null)
    {
        line.ItemId = line.Item.Id;
        db.Entry(line.Item).State = System.Data.Entity.EntityState.Detached;
    }
}

Solution #2 - Change your JSON line items to have the ItemId instead of the Item, like this

{'state': 2, 'price': 10, 'quantity': 1, 'itemId': 1}

Upvotes: 1

CodeThug
CodeThug

Reputation: 3192

The reason you are having this problem is that you are creating two different Item objects, giving them both the same key, and trying to add both of them to the context.

Item = new Item { Id = 1, Description = "Product" }

/// and later

Item = new Item { Id = 1, Description = "Product" }

The exception happens when you call .Add(), because you can't have two different objects of the same type with the same key in the EF context.

When I look at the code in your github repo, it looks like you've already solved this. All I have to do is comment out how you create the Items currently and uncomment your other code, resulting in this:

var item = new Item { Id = 1, Description = "Product" };

// Invoice is added
var invoice = new Invoice
{
    State = State.Added,
    Number = "SALE-001",
    Lines =
    {
        new InvoiceLine
        {
            State = State.Added,
            Price = 10,
            Quantity = 1,
            // Item = new Item { Id = 1, Description = "Product" }
            Item = item
        },
        new InvoiceLine
        {
            State = State.Added,
            Price = 10,
            Quantity = 1,
            DiscountPercentage = 50,
            // Item = new Item { Id = 1, Description = "Product" }
            Item = item
        },
    }
};

This way, only 1 Item ends up getting added to the context, so EF is happy.

When I run the code like that, it works without any exceptions being thrown.

Upvotes: 1

Related Questions