Skip to content

Instantly share code, notes, and snippets.

@irace
Created November 21, 2014 14:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save irace/509314c46718951137e0 to your computer and use it in GitHub Desktop.
Save irace/509314c46718951137e0 to your computer and use it in GitHub Desktop.
How to prevent Core Data objects from being orphaned when their relationship to another object is severed

Say you have a one-to-many relationship (modeled via Core Data) between something like a tweet and a bunch of photos:

@implementation Tweet

- (void)updateWithDictionary:(NSDictionary *)JSONDictionary {
    self.photos = [JSONDictionary["photos"] transformedArrayUsingBlock:^Photo *(NSDictionary *photoJSON) {
      NSEntityDescription *entityDescription = [NSEntityDescription entityForName:@"Photo" inManagedObjectContext:self.managedObjectContext];
      Photo *photo = [[Photo alloc] initWithEntity:entityDescription insertIntoManagedObjectContext:self.managedObjectContext];
      [photo updateWithDictionary:photoJSON];
      return photo;
    }];
}

This update method could theoretically be called on the same Tweet object multiple times, as the user refreshes their timeline. The code above would result in Photo objects being orphaned as new ones are created to take their place.

I can think of three decent ways to avoid this:

1) Properly merge photo data with what is already on disk

This would entail:

  • Creating identifiers for each photo
  • Updating photos that already exist
  • Creating photos that don't already exist
  • Deleting photos that exist on disk but not in the JSON response

Seems like more work than it's worth to be honest

2) Manually delete photos before creating new ones

- (void)updateWithDictionary:(NSDictionary *)JSONDictionary {
    [self.photos enumerateObjectsUsingBlock:^(Photo *photo, NSUInteger photoIndex, BOOL *stop) {
        [self.managedObjectContext deleteObject:photo];
    }];

    self.photos = [JSONDictionary["photos"] transformedArrayUsingBlock:^Photo *(NSDictionary *photoJSON) {
      NSEntityDescription *entityDescription = [NSEntityDescription entityForName:@"Photo" inManagedObjectContext:self.managedObjectContext];
      Photo *photo = [[Photo alloc] initWithEntity:entityDescription insertIntoManagedObjectContext:self.managedObjectContext];
      [photo updateWithDictionary:photoJSON];
      return photo;
    }];
}

Would result in some unnecessary deleting and re-creating of the same data, but would be far simpler than #1.

3) Have orphaned photos delete themselves

@implementation Photo

- (void)willSave {
  if (!self.tweet) {
    [self.managedObjectContext deleteObject:self];
  }
}

This is slightly cleaner than manually deleting the photos (in my opinion), but doesn't scale particularly well. It requires me to create the tweet inverse relationship (with approaches 1-2, the Photo doesn't actually need to have a reference to the Tweet).

Additionally, Photo could be generic and used in contexts other than tweets. Maybe a Photo can also be associated with a DM, or a user's avatar, in which case I'd need to create inverse relationships for all three and the willSave implementation will now look like:

@implementation Photo

- (void)willSave {
  if (!self.tweet && !self.user && !self.directMessage) {
    [self.managedObjectContext deleteObject:self];
  }
}
@mattbischoff
Copy link

I’m still a fan of 3.

@cdzombak
Copy link

I'm with @mattbischoff; I do not see adding the Tweet inverse relationship as a smell.

@r3econ
Copy link

r3econ commented Apr 19, 2016

Inverse relationships are recommended. In fact, compiler will raise a warning if you don't define one.

@richeterre
Copy link

richeterre commented Oct 7, 2021

Approach 3 worked great for me… until I added CloudKit sync (via NSPersistentCloudKitContainer), where I observed the following:

  1. My app receives tweets with photos that were added on another device
  2. willSave gets called on each photo, with the tweet relationship being nil 😮
  3. willSave gets called on each photo again, this time with a non-nil tweet (apparently this is where it gets filled in)

Result: The incoming photos get wrongly marked for deletion 😢 Any ideas how to work around this?

@estromlund
Copy link

estromlund commented Oct 7, 2021

@richeterre Another approach that I've done is some combination of:

  1. Not worry so much about it at this step. Maybe "find or create" as necessary. Modern iOS versions let you add unique constraints so step #1 becomes an upsert and that's a bit easier to manage than when this was written.
  2. In your fetch request, filter for relationship != nil so you only display or work with the complete objects
  3. Batch delete orphaned objects at some interval or when going to the background. You can add a last modified timestamp (derived attributes are great for this) to make sure objects are actually orphaned for X amount of time to avoid sync race conditions

@richeterre
Copy link

@estromlund Those are good ideas, and I’m honestly not too worried about those orphan records, but being photos they do come with a certain footprint.

#3 is definitely something to consider as a fallback option. I'm afraid #1 won't work with CloudKit, as it sadly doesn't support unique constraints… And #2 sounds like something I should generally start doing, given that CloudKit doesn't support non-optional relationships either 🙄

I wish there was a (clean) way to detect whether willSave was triggered by my own code, or CloudKit syncing data in the background. Obviously those things happen on different contexts, but that feels like a brittle foundation to base my logic on.

@estromlund
Copy link

@richeterre Hmmm, I clearly haven't worked with CloudKit 😁

If you need, there's a decently efficient implementation of findOrCreate (without constraints) in the Objc.io Core Data book here: link.

That said, if I had to ship an implementation for your problem right now, I'd go with the original option #3 but with a soft delete. Then do the actual MOC deletion later on.

I have to do this in Unlisted for Conversations. Messages find or create a conversation as they're synced from the API, but deleting all messages should delete the conversation. There are plenty of race conditions so I only soft delete the conversation, un-soft-delete it if a new message comes in for it during the same transaction, then do the actual deletion on entering the background if the soft deletion timestamp was more than a minute ago. Or something roughly like that.

Good luck! 😬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment