Search code examples
swiftcore-dataunique-constraintnsmergepolicy

How to prevent to-many relationship NSManagedObject duplicates on overwrite merge with Swift in CoreData?


My Swift app downloads 3 databases from an online API in JSON form, and then converts the JSON objects into CoreData objects, so the app can function outside of internet access.

I have an entity Client that has a toMany relationship with entities of type Address. Address has a to-one relationship with entity Client.

Client <-->> Address

The Client to Addresses relationship has a cascade delete rule, and the Address to Client relationship has a nullify delete rule.

Client has a uniqueness constraint on the id attribute, and the context always uses NSMergePolicyType.overwriteMergePolicyType.

When a new Client NSManagedObject is instantiated, the context is saved, and a Client with the same ID is found, the new Client overwrites the old one, with one big caveat - for some unknown reason the old Address objects persist, now linked to the new Client. This results in a new copy of each Address every time the cache/database is reloaded.

I have multiple entities that have relationships and uniqueness like this that are running into the same result - duplicates of to-many object instances.

For an object like Address, there is no one attribute that can encapsulate uniqueness among all other Address objects across the container. It must be a sum of all the attributes(address1, address2, city, state, zip, etc) that is checked for uniqueness against the sum of all attributes of another Address. So I'm unsure of how to accomplish this through uniqueness constraints - as far as I can tell, uniqueness constraints cannot be expanded with if logic.

The other solution would be to change the merge behavior of the policy, or create a custom merge policy, to ensure that it actually deletes the old object(cascading down to the to-many relationship objects) before replacing it with the new object.

I'm not familiar enough with CoreData or objective-c to follow anything I've been able to find on the subject so far.

Does anyone have suggestions on how to either A. expand uniqueness constraints capabilities, B. define merge policy behavior, or C. otherwise prevent the aforementioned address objects from duplicating?

edit:

I'm suspicious that my presumptions about uniqueness constraints are wrong - see my separate question about it here


Solution

  • Well, I can assure you that neither knowledge of Objective-C, nor reading Apple's non-existent documentation on subclassing NSMergePolicy would have helped you figure this one out :)

    I confirmed in my own little demo project that Core Data's Uniqueness Constraints do not play as one would expect with Core Data's Cascade Delete Rule. As you reported, in your case, you just keep getting more and more Address objects.

    The following code solves the problem of duplicated Address objects in my demo project. However its complexity makes one wonder if it would not be better to forego Core Data's Uniqueness Constraint and write your own old school uniquifying code instead. I suppose that might perform worse, but you never know.

    When de-duplicating Address objects, one could keep either the existing objects in the persistent store or make new ones. It should not matter, if indeed all attributes are equal. The following code keeps the existing objects. That has the aesthetically pleasing effect of not growing the "p" suffixes in the object identifier string representations. They remain as "p1", "p2", "p3", etc.

    When you create your persistent container, in the loadPersistentStores() completion handler, you assign your custom merge policy to the the managed object context like this:

    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        container.viewContext.mergePolicy = MyMergePolicy(merge: .overwriteMergePolicyType)
        ...
    })
    

    Finally, here is your custom merge policy. The Client objects in the merge conflicts passed to resolve(constraintConflicts list:) have their new Address objects. The override removes these, and then invokes one of Core Data's standard merge policies, which appends the existing Address objects, as desired.

    class MyMergePolicy : NSMergePolicy {
        override func resolve(constraintConflicts list: [NSConstraintConflict]) throws {
            for conflict in list {
                for object in conflict.conflictingObjects {
                    if let client = object as? Client {
                        if let addresses = client.addresses {
                            for object in addresses {
                                if let address = object as? Address {
                                    client.managedObjectContext?.delete(address)
                                }
                            }
                        }
                    }
                }
            }
    
            /* This is kind of like invoking super, except instead of super
              we invoke a singleton in the CoreData framework.  Weird. */
            try NSOverwriteMergePolicy.resolve(constraintConflicts: list)
    
            /* This section is for development verification only.  Do not ship. */
            for conflict in list {
                for object in conflict.conflictingObjects {
                    if let client = object as? Client {
                        print("Final addresses in Client \(client.identifier) \(client.objectID)")
                        if let addresses = client.addresses {
                            for object in addresses {
                                if let address = object as? Address {
                                    print("   Address: \(address.city ?? "nil city") \(address.objectID)")
                                }
                            }
                        }
                    }
                }
            }
    
        }
    }
    

    Note that this code builds on the Overwrite merge policy. I'm not sure if one of the Trump policies would be more appropriate.

    I'm pretty sure that's all you need. Let me know if I left anything out.