2013-05-28

Diffing NSManagedObjects Part 2

When I last discussed comparing two NSManagedObjects and producing a diff, I said that I’d come back to a few issues later. It turned out to be much later than expected; this post has been floating around on my hard disk for 6 months.

This time around I’ll deal with two of those issues:

  • What do you do if you want to include a related object in the diff if it has its deletion rule set to nullify?
  • What do you do if you want to exclude an attribute or relationship from the diff?

The answers are “whitelist” and “blacklist”.

In the previous post, we used deletion rules to signify object ownership. This works well, but means that we can’t automatically include crucial information from a relationship if the deletion rule indicates a lack of ownership.

For example, imagine we’re a dogfood company and have a Dog class and a Person class:

  • Person (User info: @{ @“id”: @“guid” })

    • Attributes:
      • guid
      • name
      • age
    • Relationships:
      • dog (Dog) (Delete: cascade) (To one)
  • Dog (User info: @{ @“id”: @“guid” })

    • Attributes:
      • guid
      • name
    • Relationships:
      • owner (Person) (Delete: nullify) (To one)

If we delete a person we’ll also delete his dog. We don’t care about the dog if we no longer track the person, presumably because we can’t sell anything directly to a dog. On the other hand, if we delete the dog we don’t necessarily want to delete the person. The person could get a new dog.

If a dog changes owner, it’d be handy if we could represent that information in a diff. Unfortunately, the deletion rule will prevent the automated diff methods from examining this relationship. We need to whitelist this relationship to ensure that it is included in the diff.

The whitelist looks like this:

NSDictionary *whitelistedRelationships = @{
@"Dog": @[ @"owner" ]
};

Each key in the dictionary represents the name of a class. Each value represents an array of properties of that class. In this case, the whitelist says, “If the diff creator encounters an instance of the Dog class, include the owner relationship in the diff regardless of the deletion rule”.

There are two issues here. Firstly, we’re hard-coding information that should really be metadata in the data model. Unfortunately, there’s not much we can do about that if we want to be able to produce a variety of different diffs that can be started in multiple places within the object graph.

The second issue is that we’ve re-introduced a cycle into the graph. The diff creator will repeatedly walk from the Dog to its owner and back, eventually causing a stack overflow. To get around that, we need to introduce the concept of a blacklist. Anything in a blacklist will not be examined. Here’s what the blacklist would look like in this situation:

NSDictionary *blacklistedProperties = @{
@"Owner": @[ @"dog" ]
};

The dictionary format is that same as the blacklist: each key is the name of a class, whilst each value is an array of attribute and relationship names that should be ignored by the diff creator. This particular blacklist says, “Do not traverse the dog relationship of any Owner instances”. This would be counter-productive if we were starting the diff from the owner, due to the direction of the relationship established by the deletion rule, but if we start from the dog it is very useful.

Now that we’ve decided upon workable solutions, we just need to implement them. We’ll start off with a method that can retrieve all of the property names for an NSManagedObject instance that exist in a blacklist or whitelist:

+ (NSArray *)propertyNamesForManagedObject:(NSManagedObject *)managedObject inList:(NSDictionary *)list {

    NSMutableArray *output = [NSMutableArray array];

    for (NSString *className in [list allKeys]) {
        Class class = NSClassFromString(className);

        if ([managedObject isKindOfClass:class]) {
            [output addObjectsFromArray:list[className]];
        }
    }

    return output;
}

We’ve introduced a new metaprogramming technique here: getting a pointer to a metaclass using the NSClassFromString() function. As the name suggests, we can get a class definition from a string representing its name. We can use that to determine the properties of the class.

This function will eventually give us a list of all property names of object managedObject that match a name in the list dictionary. For example, if we passed it an Owner object and the blacklist defined above, we’d get the following output:

@[ @"dog" ]

We know that the dog property of our Owner object is blacklisted and shouldn’t be traversed.

Next we’ll add a little helper method to determine if an array contains a string:

+ (BOOL)isString:(NSString *)string inList:(NSArray *)list {

    for (NSString *item in list) {
        if ([string isEqualToString:item]) {
            return YES;
        }
    }

    return NO;
}

Perhaps a set would have been a better data structure here; I might revisit that.

Now we can modify the diff method so that it checks the blacklist before attempting to examine an attribute or relationship:

...

NSArray *attributeNames = newObject == nil ? [[[oldObject entity] attributesByName] allKeys] : [[[newObject entity] attributesByName] allKeys];

NSArray *blacklist = [self propertyNamesForManagedObject:newObject ? newObject : oldObject inList:blacklistedProperties];

for (NSString *name in attributeNames) {

    if ([self isString:name inList:blacklist]) continue;

    ...

}

We do something similar to ensure that we traverse whitelisted relationships:

...

NSArray *whitelist = [self propertyNamesForManagedObject:newObject ? newObject : oldObject inList:whitelistedRelationships];

for (NSString *name in relationships) {

    NSRelationshipDescription *relationship = [relationships objectForKey:name];

    if (![self isString:name inList:whitelist]) {
        if (relationship.deleteRule != NSCascadeDeleteRule) continue;
    }

    ...
}

It’s a simple check - if the relationship is whitelisted, ignore the deletion rule.

At this point, the code is far too big to post here. I’ll create a BitBucket repository with the diff/patching system as a library with a demo project at some point. The explanation of the patching code will come later.