Cascade Delete and 1-to-1 (or 1-to-0..1) Relationships

Here’s another episode in my ongoing mission to help folks be successful using the Entity Framework, as well as to learn more about how folks use the Entity Framework so that each release will be better than the last.  Today’s task: Using cascade delete with 1-to-1 relationships.

The problem

I got an email from a customer asking for help with getting cascade delete to work properly in his application.  After going through the normal obvious potential issues, I discovered that he seemed to be doing all the right things but it still wasn’t working.  He was getting exception something like this:

System.Data.UpdateException: A relationship is being added or deleted from an AssociationSet 'FK_MyObjectDetail_MyObject'. With cardinality constraints, a corresponding 'MyObjectDetail' must also be added or deleted.

It turns out, he wasn’t the only one running into this problem.  Some links (chances are there are others):

Given that this is obviously an issue, I asked around on the team, and the result is that this is the “collision” of a couple of features.  Urrggg.  Let me see if I can explain what’s going on, and then I’ll provide a work around which seems to be solving the problem for the customer who originally contacted me.

Some background

The two colliding features are cascade delete and an important related concept we call “relationship span.” To learn more you can take a look at these blog posts:

Cascade delete --

Key lessons for us:

  1. Cascade delete in the model just deletes things that are in memory. If you want to delete things that the database has which haven’t been loaded in memory, then you need to configure cascade delete in the database as well as in the model.
  2. Cascade delete in the model kicks in when you delete the principal entity – NOT when you remove the relationship.

Relationship span --

Key lessons for us:

  1. When you have independent associations, unless you use MergeOption.NoTracking the EF will automatically retrieve the relationship info (ie. the EntityKey) for relationships where the other side has multiplicity 1 or 0..1.
  2. When you delete an entity, the EF deletes all relationships involving that entity.  
  3. When you delete a relationship, if there is a “1” side to that relationship, the EF expects the entity on that side to be deleted. (In most cases if you use EF generated classes, this will be done for you automatically.) When you think about this expectation in terms of 1-to-1 relationships, it implies that the EF expects you to delete both entities.

So what the heck is going on?

Why do 1-many relationships work, but 1-to-1 relationships don’t?  The problem here is that when I have a 1-to-1 relationship with cascade delete turned on, if I retrieve one of the entities in that relationship, the EF will automatically bring along the relationship to the other entity (that’s relationship span at work).  Then if I delete the entity I have retrieved without having loaded the other entity in the relationship, the EF won’t do the cascade itself since it will only do cascade delete on things in memory—it depends on the database to do the cascade for things that are related in the DB but haven’t been loaded in memory—but the EF WILL delete the relationship because when you delete an entity the EF always deletes all relationships to that entity which it has in memory.

So far so good.  I now have an ObjectStateManager which has an entry for the entity saying it should be deleted and an entry for the relationship saying it should be deleted.  When I call SaveChanges, though, the EF first validates the set of things that it has to do as recorded by the state manager and it discovers that a relationship is being deleted which has a “1” side where the entity is not being deleted.  This is the side of the relationship for the entity we haven’t loaded yet.  Since this relationship is 1-to-1, it doesn’t really matter which side we’re talking about, if either side is not in memory, then the EF will throw the exception which is the cause of all our pain.

The workaround

Of course the frustrating thing here is that we have already setup the database to cascade for us.  So if the EF would just delete the entity, then the database would detect that and delete the other entity and everything would be fine.  This brings us to the workaround: Since the thing triggering the validation problem is the deleted relationship, if we just get that relationship out of the state manager before we do SaveChanges, then the validation step of SaveChanges will succeed because it will only find the entity to delete.  When that delete goes through to the database, it will delete the related entity and everything will be fine.

So all we need to do is find ObjectStateEntries representing relationships in this and only this state and call AcceptChanges on them each time right before we do SaveChanges.

 public partial class MyContext
{
    partial void OnContextCreated()
    {
        this.SavingChanges += (s, e) => FixCascadeDeleteForSingularAssociations();
    }

    public void FixCascadeDeleteForSingularAssociations()
    {
        foreach (var entry in this.ObjectStateManager.GetObjectStateEntries(EntityState.Deleted)
            .Where(e => ((e.IsRelationship == true) && AssociationIsSingular(e))))
        {
            if ((CascadeDelete(entry, 0) && EndIsPresentAndDeleted(entry, 0) && !EndIsPresentAndDeleted(entry, 1))
                || (CascadeDelete(entry, 1) && EndIsPresentAndDeleted(entry, 1) && !EndIsPresentAndDeleted(entry, 0)))
            {
                // AcceptChanges on this state entry so the update pipeline ignores it -- depend on the database to do the cascade
                entry.AcceptChanges();
            }
        }
    }

    private bool CascadeDelete(ObjectStateEntry entry, int index)
    {
        var dataRecordInfo = ((IExtendedDataRecord)entry.OriginalValues).DataRecordInfo;
        var associationEndMember = dataRecordInfo.FieldMetadata[index].FieldType as AssociationEndMember;
        return (associationEndMember.DeleteBehavior == OperationAction.Cascade);
    }

    private bool EndIsPresentAndDeleted(ObjectStateEntry entry, int index)
    {
        ObjectStateEntry endEntry = null;
        if (!this.ObjectStateManager.TryGetObjectStateEntry(entry.OriginalValues[index], out endEntry))
            return false;
        return (endEntry.State == EntityState.Deleted);
    }

    private bool AssociationIsSingular(ObjectStateEntry entry)
    {
        // IsSingular in this sense means it's 1-to-1 or 1-to-0..1, not many-many or 1-many
        var dataRecordInfo = ((IExtendedDataRecord)entry.OriginalValues).DataRecordInfo;
        return (EndIsSingular(dataRecordInfo, 0) && EndIsSingular(dataRecordInfo, 1));
    }

    private bool EndIsSingular(DataRecordInfo dataRecordInfo, int index)
    {
        var associationEndMember = dataRecordInfo.FieldMetadata[index].FieldType as AssociationEndMember;
        return (associationEndMember.RelationshipMultiplicity != RelationshipMultiplicity.Many);
    }
}

The key ideas behind this code are:

  1. We put these methods in the partial class for our context and use the OnContextCreated partial method to make sure we register a handler for the SavingChanges event every time a context instance is created.
  2. The main part of the event handler is a LINQ to Objects query over the ObjectStateEntries which are deleted 1-to-1 relationships.
  3. We get metadata about the relationships by looking at the DataRecordInfo.FieldMetadata for the OriginalValues data record on the ObjectStateEntry.  This data record has two columns – each containing the EntityKey of an entity participating in this relationship.  The field metadata can tell us if cascade delete is defined as well as the multiplicity of that end of the relationship.
  4. Since the relationship entry is in the deleted state, if we call its AcceptChanges method, then it will be detached from the context as though it never existed.

Once we put this code in place, each time SaveChanges is called, the offending relationship entries will be hunted down and killed before a save happens.

Oh, and if you prefer VB.net, here’s the VB version of this code.  Note that I have not tested the VB version, but others have, and it seems to be OK…

 Partial Public Class CascadeContainer

    Private Sub CascadeContainer_SavingChanges(ByVal sender As Object, ByVal e As System.EventArgs) _
         Handles Me.SavingChanges
        Me.FixCascadeDeleteForSingularAssociations()
    End Sub

    Public Sub FixCascadeDeleteForSingularAssociations()
        For Each entry In Me.ObjectStateManager.GetObjectStateEntries(EntityState.Deleted).Where( _
                Function(e) ((e.IsRelationship = True) AndAlso AssociationIsSingular(e)))
            If (CascadeDelete(entry, 0) AndAlso EndIsPresentAndDeleted(entry, 0) _
               AndAlso Not EndIsPresentAndDeleted(entry, 1)) _
               OrElse (CascadeDelete(entry, 1) AndAlso EndIsPresentAndDeleted(entry, 1) _
               AndAlso Not EndIsPresentAndDeleted(entry, 0)) Then
                ' AcceptChanges on this state entry so the update pipeline ignores it -- 
                ' depend on the database to do the cascade
                entry.AcceptChanges()
            End If
        Next
    End Sub

    Private Function CascadeDelete(ByVal entry As ObjectStateEntry, ByVal index As Integer) As Boolean
        Dim dataRecordInfo = DirectCast(entry.OriginalValues, IExtendedDataRecord).DataRecordInfo
        Dim associationEndMember = TryCast(dataRecordInfo.FieldMetadata(index).FieldType, AssociationEndMember)
        Return (associationEndMember.DeleteBehavior = OperationAction.Cascade)
    End Function

    Private Function EndIsPresentAndDeleted(ByVal entry As ObjectStateEntry, ByVal index As Integer) As Boolean
        Dim endEntry As ObjectStateEntry = Nothing
        If Not Me.ObjectStateManager.TryGetObjectStateEntry(entry.OriginalValues(index), endEntry) Then
            Return False
        End If
        Return (endEntry.State = EntityState.Deleted)
    End Function

    Private Function AssociationIsSingular(ByVal entry As ObjectStateEntry) As Boolean
        ' IsSingular in this sense means it's 1-to-1 or 1-to-0..1, not many-many or 1-many
        Dim dataRecordInfo = DirectCast(entry.OriginalValues, IExtendedDataRecord).DataRecordInfo
        Return (EndIsSingular(dataRecordInfo, 0) AndAlso EndIsSingular(dataRecordInfo, 1))
    End Function

    Private Function EndIsSingular(ByVal dataRecordInfo As DataRecordInfo, ByVal index As Integer) As Boolean
        Dim associationEndMember = TryCast(dataRecordInfo.FieldMetadata(index).FieldType, AssociationEndMember)
        Return (associationEndMember.RelationshipMultiplicity <> RelationshipMultiplicity.Many)
    End Function

End Class

- Danny