Can't batch delete with one-to-many to self relationship

I have a simple model that contains a one-to-many relationship to itself to represent a simple tree structure. It is set to cascade deletes so deleting the parent node deletes the children.

Unfortunately I get an error when I try to batch delete. A test demonstrates:

@Model final class TreeNode {   
  var parent: TreeNode?    
  @Relationship(deleteRule: .cascade, inverse: \TreeNode.parent)    
  var children: [TreeNode] = []    
  init(parent: TreeNode? = nil) {        
    self.parent = parent    
  }
}

func testBatchDelete() throws {        
  let config = ModelConfiguration(isStoredInMemoryOnly: true)        
  let container = try ModelContainer(for: TreeNode.self, configurations: config)                
  let context = ModelContext(container)        
  context.autosaveEnabled = false              
  let root = TreeNode()        
  context.insert(root)      
          
  for _ in 0..<10 {           
    let child = TreeNode(parent: root)            
    context.insert(child)        
  }                
  try context.save()   

  // fails if first item doesn't have a nil parent, succeeds otherwise        
  // which row is first is random, so will succeed sometimes        
  try context.delete(model: TreeNode.self)       
}

The error raised is:

CoreData: error: Unhandled opt lock error from executeBatchDeleteRequest Constraint trigger violation: Batch delete failed due to mandatory OTO nullify inverse on TreeNode/parent and userInfo {    
    NSExceptionOmitCallstacks = 1;    
    NSLocalizedFailureReason = "Constraint trigger violation: Batch delete failed due to mandatory OTO nullify inverse on TreeNode/parent";    
    "_NSCoreDataOptimisticLockingFailureConflictsKey" =     (    );
}

Interestingly, if the first record when doing an unsorted query happens to be the parent node, it works correctly, so the above unit test will actually work sometimes.

Now, this can be "solved" by changing the reverse relationship to an optional like so:

    @Relationship(deleteRule: .cascade, inverse: \TreeNode.parent)    
    var children: [TreeNode]?

The above delete will work fine. However, this causes issues with predicates that test counts in children, like for instance deleting only nodes where children is empty for example:

    try context.delete(model: TreeNode.self,                           
                                   where: #Predicate { $0.children?.isEmpty ?? true })

It ends up crashing and dumps a stacktrace to the console with:

An uncaught exception was raised
Keypath containing KVC aggregate where there shouldn't be one; failed to handle children.@count

(the stacktrace is quite long and deep in CoreData's NSSQLGenerator)

Does anyone know how to work around this?

I've submitted this as FB16292376

Since order of database records appears to be an issue, sticking a context.save() after inserting the root node fixes the problem.

let root = TreeNode()        
context.insert(root)       
try context.save()

for _ in 0..<100 {
...

However, this workaround isn't usable if there are mutations. For instance, creating a new branch node and moving all the root.children to it would lead to the same problem. So would creating a new parent node of root later.

Internally this appears to be caused by this SQLite trigger created by Core Data:

CREATE TEMPORARY TRIGGER IF NOT EXISTS ZQ_ZTREENODE_TRIGGER AFTER DELETE ON ZTREENODE FOR EACH ROW 
BEGIN 
    DELETE FROM ZTREENODE WHERE ZPARENT = OLD.Z_PK; 
    SELECT RAISE(FAIL, 'Batch delete failed due to mandatory OTO nullify inverse on TreeNode/parent') FROM ZTREENODE WHERE Z_PK = OLD.ZPARENT;  
END

If the deletes happened such that parents were always deleted before children, this would work. Unfortunately that's not the case.

I've submitted the crash with optional reverse relationships as a second bug FB16292988

This seems like expected behavior to me and not a bug.

Thanks for filing the feedback reports. I think that is a case Core Data can handle better.

For a workaround, you mentioned that it wouldn't be the case that "deletes happened such that parents were always deleted before children," but you can fetch the nodes whose parent attribute is nil, and delete the result set, can't you?

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Can't batch delete with one-to-many to self relationship
 
 
Q