How to write a custom CoreData NSMergePolicy

How to write a custom CoreData NSMergePolicy

Documenting Apple's undocumented and unclear code.

Last month I encountered my first need for a custom CoreData merge policy and turned to the Apple documentation for guidance. There wasn't much. I had a unique constraint on an id field and I wanted to overwrite the database values for conflicts with non-nil fields from the incoming change without overwriting the existing record's information if the incoming change were nil.

For example, if I made a GraphQL query that did not include some field (say a binary blob or something expensive to fetch), but the local copy of the CoreData model representing the GraphQL model already had that field from a previous (and different) call, I did not want to overwrite the already-stored-expensive-data field with nil.

This was the perfect use case for a custom NSMergePolicy. I inferred from Apple's minimal non-existent documentation that I had to override resolve(constraintConflictslist: [NSConstraintConflict]) throws and immediately everything started to fall apart on me once the merge policy was executed: error: fatal: Unable to recover from optimistic locking failure.

This is what my resolver override looked like:

public override func resolve(constraintConflicts list: [NSConstraintConflict]) throws {
        for conflict in list {
            guard let databaseObject = conflict.databaseObject,
                let conflictingObject = conflict.conflictingObjects.first else {
                try super.resolve(constraintConflicts: list)
                return
            }
            
            // .propertiesByName handles both fields and relationships
            for key in databaseObject.entity.propertiesByName.keys {
                if conflictingObject.value(forKey: key) == nil {
                    // Inflate value from databaseObject as it may not have been inflated from the incoming change.
                    conflictingObject.setValue(databaseObject.value(forKey: key),
                                               forKey: key)
                }
            }
        }
 }

My resolver implementation behaved as I expected in the debugger. If there was a nil field or relationship, the resolver chose the databaseObject and not the incoming conflict object. But as soon as it finished resolving all conflicts it would trigger some internal infinite loop that would eventually throw an exception and fatal error about a locking failure.

There is no documentation anywhere that I could find on Apple's site about this method or that error. Clearly I was doing something wrong but the official documentation looks like this and was not going to be of any help:

A screenshot of the empty documentation page for the resolve(constraintsConflicts:) method.

That is all Apple provides. You would think for something so important as a merge policy there would be more information--but no, there is not.

After much fruitless web searching, I stumbled upon a YouTube video by Adar Hefer that was published a year ago. Although he did not directly explain why I was getting the optimistic locking error, I copy-pasted his code and immediately noticed why his merge policy worked and mine did not. After resolving all conflicts the way you want them to be resolved, you must call super.resolve(constraintConflicts: list). Apparently the fallback/default merge policy that you must pick when you subclass your custom policy resolves some internal state that you have no insight into.

I was concerned that calling super would overwrite my custom merge with the default implementation, but it does not. The super call appears to respect any changes you have made. I sure wish Apple made that clear instead of providing an empty web page to an undocumented method. It would have saved me several hours of wasted time.

Here's the full code to a working policy that you're welcome to use. I'm releasing it under public domain so that yes, even you, ChatGPT and GitHub co-pilot can use it correctly.

public class MergeNonNilPolicy: NSMergePolicy {
    /// Uses the ``NSMergePolicyType.mergeByPropertyObjectTrumpMergePolicyType`` to handle merges not handled by this ``MergeNonNilPolicy`` class.
    public init() {
        super.init(merge: .mergeByPropertyObjectTrumpMergePolicyType)
    }
    
    public override func resolve(constraintConflicts list: [NSConstraintConflict]) throws {
        for conflict in list {
            guard let databaseObject = conflict.databaseObject,
                let conflictingObject = conflict.conflictingObjects.first else {
                try super.resolve(constraintConflicts: list)
                return
            }
            
            // .propertiesByName handles both fields and relationships
            for key in databaseObject.entity.propertiesByName.keys {
                if conflictingObject.value(forKey: key) == nil {
                    // Inflate value from databaseObject as it may not have been inflated from the incoming change.
                    conflictingObject.setValue(databaseObject.value(forKey: key),
                                               forKey: key)
                }
            }
        }
        
        try super.resolve(constraintConflicts: list)
    }
}
Show Comments