1

I have a class in my model that can refer to the same child class from two different FK associations/fields. Both of these references are populated with the same instance of the child object when the parent is created, and then later, one of the two children can be updated or changed (this does not always happen), and the original is kept because the other of the children is never touched. I hope that makes sense.

When a Parent is created or pulled from the database that has the same Child referenced twice, when you try to add the Parent to the DbContext we're seeing the dreaded error: An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key. This is being thrown because the DbContext is trying to add the entire object graph to its change tracker, i.e. the two Child references pointing to the same Child object.

We don't need change tracking. We don't mind throwing a fully populated UPDATE statement at the database. Is there a way to force the DbContext to not add the entire object graph, to only add the single instance we're telling it to add? If so, what functionality are we going to lose if we disable this globally?

EDIT: Updated code sample.

EDIT 2: Updated code sample to include serialization to mimic web service interaction.

[TestClass]
public class EntityFrameworkTests
{
  [TestMethod]
  public void ObjectGraphTest()
  {
    Database.DefaultConnectionFactory = new SqlCeConnectionFactory("System.Data.SqlServerCe.4.0");
    Database.SetInitializer(new DropCreateDatabaseAlways<MyDbContext>());

    string connectionString = String.Format("Data Source={0}\\EntityFrameworkTests.sdf", Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
    MyDbContext context = new MyDbContext(connectionString);

    Child child = new Child() { ID = 1, SomeProperty = "test value" };
    //context.Entry<Child>(child).State = EntityState.Added;
    Parent parent = new Parent()
    {
      ID = 1,
      SomeProperty = "some value",
      OriginalChild = child,
      ChangeableChild = child
    };
    context.Entry<Parent>(parent).State = EntityState.Added;
    context.SaveChanges();

    context = new MyDbContext(connectionString);
    //parent = context.Set<Parent>().AsNoTracking().Include(p => p.OriginalChild).Include(p => p.ChangeableChild).FirstOrDefault();
    parent = context.Set<Parent>().Include(p => p.OriginalChild).Include(p => p.ChangeableChild).FirstOrDefault();

    // mimic receiving object via a web service
    SaveToStorage(parent);
    parent = GetSavedItem(1);

    parent.SomeProperty = "some new value";
    context = new MyDbContext(connectionString);
    context.Entry<Parent>(parent).State = EntityState.Modified; // error here
    context.SaveChanges();
  }
}

Serialization methods to mimic web service interaction:

private void SaveToStorage(Parent parent)
{
  string savedFilePath = String.Format("{0}\\Parent{1}.xml", Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), parent.ID);

  using (FileStream fileStream = new FileStream(savedFilePath, FileMode.Create, FileAccess.Write))
  {
    using (XmlWriter writer = XmlWriter.Create(fileStream))
    {
      DataContractSerializer serializer = new DataContractSerializer(typeof(Parent));
      serializer.WriteObject(writer, parent);
    }
  }
}

private Parent GetSavedItem(int parentID)
{
  string savedFilePath = String.Format("{0}\\Parent{1}.xml", Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), parentID);

  using (FileStream fileStream = new FileStream(savedFilePath, FileMode.Open, FileAccess.Read))
  {
    using (XmlDictionaryReader xmlReader = XmlDictionaryReader.CreateTextReader(fileStream, new XmlDictionaryReaderQuotas()))
    {
      DataContractSerializer serializer = new DataContractSerializer(typeof(Parent));
      Parent savedItem = (Parent)serializer.ReadObject(xmlReader, true);
      return savedItem;
    }
  }
}

Classes used (updated for serialization):

[DataContract]
internal class Child
{
  [DataMember]
  public int ID { get; set; }
  [DataMember]
  public string SomeProperty { get; set; }
}

[DataContract]
internal class Parent
{
  [DataMember]
  public int ID { get; set; }
  [DataMember]
  public string SomeProperty { get; set; }

  [DataMember]
  public int OriginalChildID { get; set; }
  [DataMember]
  public Child OriginalChild { get; set; }
  [DataMember]
  public int ChangeableChildID { get; set; }
  [DataMember]
  public Child ChangeableChild { get; set; }
}

internal class MyDbContext : DbContext
{
  public DbSet<Parent> Parents { get; set; }
  public DbSet<Child> Children { get; set; }

  public MyDbContext(string connectionString)
    : base(connectionString) { }

  protected override void OnModelCreating(DbModelBuilder modelBuilder)
  {
    modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
  }
}
sliderhouserules
  • 3,415
  • 24
  • 32
  • did you try setting the scalar properties `OriginalChildID = myChild.ID` ? – Eranga Jun 08 '11 at 23:12
  • The code you are showing does not throw an exception. It's legal to have two references pointing to the same object in the context (`myChild` in your example). You would get a problem if you have *different objects* with the *same key* in the context. But that's not the case in your sample code. Something important must be missing in your example. – Slauma Jun 09 '11 at 15:04
  • This seemed like the easiest example. I should have taken the time to write a repeatable unit test with just the simple classes involved. The real case happens in our large system when a parent object is pulled from the database with includes that bring the two children with it. A single field on the parent is updated, and then the parent is added back to the DbContext to be saved to the database. When the parent is added back to the DbContext is when the error occurs. I'll write a repeatable unit test and update my post. Thanks. – sliderhouserules Jun 09 '11 at 16:41
  • Updated with new code that shows the error. – sliderhouserules Jun 09 '11 at 17:16
  • I think you must create a new context after `GetSavedItem` to show the actual problem you're having. Your example now will of course throw an exception because the old `parent` is still in the context (parents are now duplicate and all children too). But that's not the exception you are talking about. – Slauma Jun 09 '11 at 20:55

1 Answers1

3

The ugly solution:

Child originalChild = parent.OriginalChild;
Child changeableChild = parent.ChangeableChild;
parent.OriginalChild = null;
parent.ChangeableChild = null;

context.Entry<Parent>(parent).State = EntityState.Modified;
context.SaveChanges();

parent.OriginalChild = originalChild;
parent.ChangeableChild = changeableChild;

I you don't need the parent with the child objects anymore after saving setting the children to null would be sufficient of course.

Another and much better solution: Pull the original parent from the database again - without the children, since you know that you only want to save changed parent properties:

var originalParent = context.Set<Parent>()
    .Where(p => p.ID == parent.ID)
    .FirstOrDefault();

context.Entry(originalParent).CurrentValues.SetValues(parent);
context.SaveChanges();

You have to load the parent first (with active change tracking!) from the database but on the other hand the UPDATE command issued here will only contain the changed properties. Since you say that you don't mind to send a full UPDATE command (by setting the state to Modified) I guess you don't have performance issues. So, loading the original and then sending a small UPDATE with only the changed properties might not be worse or much worse performance-wise than sending a full UPDATE command.

Slauma
  • 175,098
  • 59
  • 401
  • 420
  • I might have to rework the core of my repository to use the second method you outline here. I've been mulling this over in my head for a couple weeks anyway, and if it fixes these problems... – sliderhouserules Jun 09 '11 at 21:03
  • Changing this line of code `context.Entry(parent).State = EntityState.Modified; // error here` to this `Parent originalParent = context.Set().FirstOrDefault();` `context.Entry(originalParent).CurrentValues.SetValues(parent);` fixes the problem for the code sample in the question. So it looks like I will be reworking my repository design. Thanks. – sliderhouserules Jun 09 '11 at 21:30