3

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

A. L. Strine
  • 611
  • 1
  • 7
  • 23

2 Answers2

5

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.

Jerry Krinock
  • 4,860
  • 33
  • 39
  • Thank you for your answer, I really appreciate it! Just to clarify, deleting the Address objects from the Client in conflict.conflictingObjects will remove the Address objects from the existing persisting Client, and not the fresh, trying to persist Client, right? To rephrase, the documentation says NSConstraintConflict.conflictingObjects is "The managed objects that are in conflict.", and I want to know if I can interpret that to mean "The managed objects that are attempting to save that are in conflict with already-saved objects." – A. L. Strine May 25 '19 at 16:25
  • As you've noted, the documentation is ambiguous. So we must determine by experiment. Looking at my little demo, the answers to the the two questions in your comment are, I think, "no" and "yes", in that order. In other words, the conflict resolution done by the code in my answer replaces the old Clients with new Clients. I say this because, unlike the behavior with the Addresses, the "p" suffixes in the Client objects' object identifiers are changed (increased) after the conflict resolution. To see this, run the *section for development verification only* which I've added to my answer. – Jerry Krinock May 26 '19 at 02:24
  • It really does not matter unless there are other attributes that are different. Since you are synchronizing attributes only via JSON, the Core Data Object IDs are only in your local persistent store. If other attributes are different, you will get the attributes from the server, which is what you want, if the server has better, later information, as is usually the case. – Jerry Krinock May 26 '19 at 02:29
  • But if on the other hand you want to keep the attributes of the existing local Client, you may be able to retain the existing local Client instead by changing from the Overwrite merge policy to one of the others. Or maybe not. If not, in that `resolve(constraintConflicts list:)` function you could, I suppose, maybe get the local Client's attributes (with a fetch?) and then overwrite the attributes. – Jerry Krinock May 26 '19 at 02:33
  • It seems like the NSConstraintConflict object should include both the new and old objects, so you could pick which one you want, or at least get their attributes. That would make this much easier. Maybe I did not try hard enough, but I don't see that. The `conflictingObjects` seem to be only the new objects. It may be that the old ones are already gone at this point. I really don't know what they were thinking :) – Jerry Krinock May 26 '19 at 02:37
  • Thank you for your time and effort! That answers my questions. – A. L. Strine May 28 '19 at 17:27
0

Not sure this is relevant anymore but I've had to deal with a similar situation. I also had associated objects that didn't seem to have anything unique about them that I wanted overwritten, but if you think about it - they do have something unique about them: It's the fact they're related to a unique object in a specific way. So the way I did it is I concatenated enough data about my "Address" object, and appended the unique key for my "Client", and that was the unique key for my "Address".

Adar Hefer
  • 1,514
  • 1
  • 12
  • 18