views:

126

answers:

2

Can EF really be this bad? Maybe...

Let's say I have a fully loaded, disconnected object graph that looks like this:

myReport = 
{Report}
  {ReportEdit {User: "JohnDoe"}}
  {ReportEdit {User: "JohnDoe"}}

Basically a report with 2 edits that were done by the same user.

And then I do this:

EntityContext.Attach(myReport);

InvalidOperationException: An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key.

Why? Because the EF is trying to attach the {User: "JohnDoe"} entity TWICE.

This will work:

myReport =
{Report}
  {ReportEdit {User: "JohnDoe"}}

EntityContext.Attach(myReport);

No problems here because the {User: "JohnDoe"} entity only appears in the object graph once.

What's more, since you can't control how the EF attaches an entity, there is no way to stop it from attaching the entire object graph. So really if you want to reattach a complex entity that contains more than one reference to the same entity... well, good luck.

At least that's how it looks to me. Any comments?

UPDATE: Added sample code:


// Load the report 
Report theReport;
using (var context1 = new TestEntities())
{
    context1.Reports.MergeOption = MergeOption.NoTracking;
    theReport = (from r in context1.Reports.Include("ReportEdits.User")
                 where r.Id == reportId
                 select r).First();
}

// theReport looks like this:
// {Report[Id=1]}
//   {ReportEdit[Id=1] {User[Id=1,Name="John Doe"]}
//   {ReportEdit[Id=2] {User[Id=1,Name="John Doe"]}

// Try to re-attach the report object graph
using (var context2 = new TestEntities())
{
    context2.Attach(theReport); // InvalidOperationException
}
+2  A: 

The problem is that you modified the default MergeOption:

context1.Reports.MergeOption = MergeOption.NoTracking;

Entities retrieved with NoTracking are intended for read-only use because there is no fixup; this is in the documentation for MergeOption. Because you set NoTracking, you now have two entirely separate copies of {User: "JohnDoe"}; without fixup the "duplicate" references don't get boiled down to a single instance.

Now when you try to save "both" copies of {User: "JohnDoe"}, the first succeeds in being added to the context, but the second can't be added because of the key violation.

Craig Stuntz
+1  A: 

After reading the EF documentation again (the v4 stuff - it's better than the 3.5 stuff) and reading this post, I realized the issue - and a work around.

With MergeOption.NoTracking, the EF creates an object graph where each entity reference is a distinct instance of the entity. So in my example, both User references on the 2 ReportEdits are distinct objects - even though all their properties are the same. They are both in the Detached state, and they both have EntityKeys with the same value.

The trouble is, when using the Attach method on the ObjectContext, the context reattaches each User instance based on the fact that they are separate instances - it ignores the fact that they have the same EntityKey.

This behavior makes sense, I suppose. If the entities are in the detached state, the EF doesn't know if one of the two references has been modified, etc. So instead of assuming they are both unchanged and treating them as equal, we get an InvalidOperationException.

But what if, like in my case, you know that both User references in the detached state are in fact the same and want them to be treated as equal when they are reattached? Turns out the solution is simple enough: If an entity is referenced multiple times in the graph, each one of those references needs to point to a single instance of the object.

Using the IEntityWithRelationships, we can traverse the detached object graph and update the references and consolidate duplicate references to the same entity instance. ObjectContext will then treat any duplicate entity references as the same entity and reattach it without any error.

Based loosely on the blog post I referenced above, I've created a class to consolidate references to duplicate entities so that they share the same object reference. Keep in mind that if any of the duplicate references have been modfied while in the detached state, you'll end up with unpredicatable results: the first entity found in the graph always takes precedence. In specific scenarios though, it seems to do the trick.


public class EntityReferenceManager
{
    /// 
    /// A mapping of the first entity found with a given key.
    /// 
    private Dictionary _entityMap;

    /// 
    /// Entities that have been searched already, to limit recursion.
    /// 
    private List _processedEntities;

    /// 
    /// Recursively searches through the relationships on an entity
    /// and looks for duplicate entities based on their EntityKey.
    /// 
    /// If a duplicate entity is found, it is replaced by the first
    /// existing entity of the same key (regardless of where it is found 
    /// in the object graph).
    /// 
    /// 
    public void ConsolidateDuplicateRefences(IEntityWithRelationships ewr)
    {
        _entityMap = new Dictionary();
        _processedEntities = new List();

        ConsolidateDuplicateReferences(ewr, 0);

        _entityMap = null;
        _processedEntities = null;
    }

    private void ConsolidateDuplicateReferences(IEntityWithRelationships ewr, int level)
    {
        // Prevent unlimited recursion
        if (_processedEntities.Contains(ewr))
        {
            return;
        }
        _processedEntities.Add(ewr);

        foreach (var end in ewr.RelationshipManager.GetAllRelatedEnds())
        {
            if (end is IEnumerable)
            {
                // The end is a collection of entities
                var endEnum = (IEnumerable)end;
                foreach (var endValue in endEnum)
                {
                    if (endValue is IEntityWithKey)
                    {
                        var entity = (IEntityWithKey)endValue;
                        // Check if an object with the same key exists elsewhere in the graph
                        if (_entityMap.ContainsKey(entity.EntityKey))
                        {
                            // Check if the object reference differs from the existing entity
                            if (_entityMap[entity.EntityKey] != entity)
                            {
                                // Two objects with the same key in an EntityCollection - I don't think it's possible to fix this... 
                                // But can it actually occur in the first place?
                                throw new NotSupportedException("Cannot handle duplicate entities in a collection");
                            }
                        }
                        else
                        {
                            // First entity with this key in the graph
                            _entityMap.Add(entity.EntityKey, entity);
                        }
                    }
                    if (endValue is IEntityWithRelationships)
                    {
                        // Recursively process relationships on this entity
                        ConsolidateDuplicateReferences((IEntityWithRelationships)endValue, level + 1);
                    }
                }
            }
            else if (end is EntityReference) 
            {
                // The end is a reference to a single entity
                var endRef = (EntityReference)end;
                var pValue = endRef.GetType().GetProperty("Value");
                var endValue = pValue.GetValue(endRef, null);
                if (endValue is IEntityWithKey)
                {
                    var entity = (IEntityWithKey)endValue;
                    // Check if an object with the same key exists elsewhere in the graph
                    if (_entityMap.ContainsKey(entity.EntityKey))
                    {
                        // Check if the object reference differs from the existing entity
                        if (_entityMap[entity.EntityKey] != entity)
                        {
                            // Update the reference to the existing entity object
                            pValue.SetValue(endRef, _entityMap[endRef.EntityKey], null);
                        }
                    }
                    else
                    {
                        // First entity with this key in the graph
                        _entityMap.Add(entity.EntityKey, entity);
                    }
                }
                if (endValue is IEntityWithRelationships)
                {
                    // Recursively process relationships on this entity
                    ConsolidateDuplicateReferences((IEntityWithRelationships)endValue, level + 1);
                }
            }
        }
    }
}
dkr88