Dynamically Adding Attributes to Core Data Entities, Part 2
Last time, I talked about adding attributes to an entity dynamically. At the same time I was complaining that the code was too complicated, and how I really should be using a transient attribute.
But first, let me recap what I mean by dynamic attributes. They are supposed to behave like normal attributes with the following differences:
- You can add attributes without changing your data model, you can even have attributes that are not known until runtime.
- You can store property lists, including arrays and dictionaries.
- Dynamic attributes can't be used in fetch requests.
The method I am describing here has special handling for multiple managed object contexts writing to the attributes. If you only have a single context, what I am doing here is a bit too much.
Let's see how the old code can be cleaned up.
I am going to move the extendedChanges instance
variable into a transient attribute. That should give us automatic
undo support and easier handling of
-[refreshObject:mergeChanges:YES]. The data model
looks like this:
entity MyEntity {
class ExtendedManagedObject;
// other attributes...
binary extendedData;
transient undefined extendedChanges;
}
I need a single instance variable in my
NSManagedObject subclass:
@interface ExtendedManagedObject : NSManagedObject {
NSDictionary *extendedSnapshot;
}
@end
The snapshot is computed on demand by unarchiving the
extendedData plist, exactly like before:
- (NSDictionary*)extendedSnapshot
{
if (extendedSnapshot!=nil)
return extendedSnapshot;
id data = [[self committedValuesForKeys:[NSArray arrayWithObject:@"extendedData"]]
objectForKey:@"extendedData"];
if (data!=nil && data!=[NSNull null]) {
NSString *error=nil;
extendedSnapshot = [[NSPropertyListSerialization propertyListFromData:data
mutabilityOption:NSPropertyListImmutable
format:nil errorDescription:&error] retain];
if (error!=nil) {
NSLog(@"Error loading extended data: %@", error);
[error release];
}
}
else {
extendedSnapshot = [[NSDictionary alloc] init];
}
return extendedSnapshot;
}
The snapshot represents the value of extendedData
in the current database snapshot, so we need to make sure
it gets reloaded whenever the snapshot changes. That is in
didTurnIntoFault and didSave.
- (void)didTurnIntoFault
{
[super didTurnIntoFault];
[extendedSnapshot release];
extendedSnapshot = nil;
}
Getting the value of an extended attribute is almost the same as before, except for the use of a transient attribute instead of an instance variable:
- (id)valueForExtendedKey:(NSString*)key
{
id value = [[self valueForKey:@"extendedChanges"] objectForKey:key];
if (value!=nil)
return value==[NSNull null] ? nil : [[value retain] autorelease];
else
return [[[[self extendedSnapshot] objectForKey:key] retain] autorelease];
}
When changing an attribute, we need to copy the
extendedChanges dictionary. This is to ensure that the
undo manager does not get confused, as it surely would if we made
changes to the contents of a mutable transient attribute.
- (void)setValue:(id)value forExtendedKey:(NSString*)key
{
NSDictionary *changes = [self valueForKey:@"extendedChanges"];
NSMutableDictionary *changesCopy;
if (changes!=nil)
changesCopy = [changes mutableCopy];
else
changesCopy = [[NSMutableDictionary alloc] init];
if (value==nil)
value = [NSNull null];
[changesCopy setObject:value forKey:key]:
[self setValue:changesCopy forKey:@"extendedChanges"]
[changesCopy release];
}
We got rid of the messages to the undo manager. Since
extendedChanges is a transient attribute, it is
handled automatically by Core Data, and undo should work. I have
also removed the KVO notifications, not because they were wrong,
but they were not sufficient. There are several ways an attribute
can change:
-[setValue:forKey]- Undo/redo
-[refreshObject:mergeChanges:]
Putting in -[willChangeValueForKey:] and
-[didChangeValueForKey:] only handles KVO
notifications for the first case. To get at the other two cases, we
must tell Core Data that the new attribute depends on
extendedChanges and extendedData:
+ (void)initialize
{
...
[self setKeys:[NSArray arrayWithObjects:@"extendedChanges", @"extendedData", nil]
triggerChangeNotificationsForDependentKey:@"myAttrName"];
}
The class message must be sent before any instances are created, so this method won't work for keys that are not known at compile time. You can still use runtime-defined keys, you just won't get KVO notifications for them.
Saving works just like before:
- (void)willSave
{
NSDictionary *changes = [self valueForKey:@"extendedChanges"];
if (changes!=nil) {
// merge changes into snapshot
NSMutableDictionary *dict = [[self extendedSnapshot] mutableCopy];
NSEnumerator *e = [changes keyEnumerator];
NSString *key;
while (key=[e nextObject]) {
id value = [changes objectForKey:key];
if (value==[NSNull null])
[dict removeObjectForKey:key];
else
[dict setObject:value forKey:key];
}
// archive as binary plist
NSData *data = nil;
if ([dict count]>0) {
NSString *error=nil;
data = [NSPropertyListSerialization dataFromPropertyList:dict
format:NSPropertyListBinaryFormat_v1_0 errorDescription:&error];
if (error!=nil) {
NSLog(@"Error saving extended data: %@", error);
[error release];
}
}
[dict release];
[self setPrimitiveValue:data forKey:EXTDATAKEY];
}
[super willSave];
}
In didSave, remember to clear both
extendedChanges and extendedSnapshot:
- (void)didSave
{
[super didSave];
[extendedSnapshot release];
extendedSnapshot = nil;
[self setPrimitiveValue:nil forKey:@"extendedChanges"];
}
That is all. We no longer need to subclass
NSManagedObjectContext, and undo is handled
automatically. The price we pay is copying the
extendedChanges dictionary whenever an extended
attribute is changed. It is just a shallow copy, though, so it's
not so bad.
Accessors
There are two ways of writing accessors for extended attributes. One is to use the general KVC fallback mechanism:
- (id)valueForUndefinedKey:(NSString*)key
{
return [self valueForExtendedKey:key];
}
That way, our class is suddenly KVC compliant for any key. This is a bit dangerous because a misspelled key just becomes a new attribute. You won't get any warnings, just values disappearing mysteriously. A safer approach is to write a named accessor method:
- (id)myAttrName
{
return [self valueForExtendedKey:@"myAttrName"];
}
Finally, for runtime-defined keys, you can just call
-[valueForExtendedKey:] directly. Remember that KVO
depends on -[valueForKey:], so if you enable KVO for a
key, you must make sure that the class is KVC compliant for that
key.
How it works
When an extended managed object is first inserted,
extendedSnapshot is empty and
extendedChanges is nil, so
-[valueForExtendedKey:] returns nil for
any key. Whenever you set an extended attribute, a copy of
extendedChanges is made with the new value and stored
in the transient attribute.
Saving the managed object context merges the changes into
extendedSnapshot, and that is archived as a binary
plist in the extendedData attribute. Note that
willSave changes neither extendedSnapshot
nor extendedChanges. If the save fails for some
reason, we want to still keep track of the changes, therefore
extendedSnapshot and extendedChanges are
not cleared until didSave. That is also the reason
-[extendedSnapshot] uses
-[committedValuesForKeys:] raher than the simpler
-[valueForKey:] when retrieving the archived plist. A
failed save followed by a refresh would otherwise cause us to use
the wrong data for the snapshot.
Refreshing the object works.
[refreshObject:mergeChanges:NO] clears all transient
attributes, including our changes.
[refreshObject:mergeChanges:YES] will clear
extendedSnapshot in didTurnIntoFault, but
preserve extendedChanges. Next time we access an
attribute, extendedSnapshot is reloaded, including any
changes made by another context. This means that attributes we
haven't touched are refreshed, while the attributes we changed are
preserved. Note that the semantics here are different from standard
attributes. Core Data will compare values when determining
if an attribute is changed, but extended attributes use the
"-[setValue:forKey:] called" criteria. The difference
is subtle.
Undo and redo works too. Transient attributes are reverted to
previous values, including the extendedChanges
dictionary. All our changes are reverted.
Saving
I forgot to mention this last time. If you have multiple contexts altering your extended attributes, you need special code for saving. Suppose one context changes the attribute "foo" while another context changes "bar". Now they both want to save. For normal attributes, you can handle this by setting a NSMergeByPropertyObjectTrumpMergePolicy, but that won't work for extended attributes. They are all stored in the same binary attribute, and of course Core Data has no way of knowing how to merge them properly. If you use NSMergeByPropertyObjectTrumpMergePolicy, it effectively becomes NSOverwriteMergePolicy for extended attributes. Not good.
To fix this, we set an NSErrorMergePolicy (the default) and merge conflicting objects manually:
NSError *error = nil;
while (![moc save:&error]) {
if ([[error domain] isEqualToString:NSCocoaErrorDomain]
&& [error code]==NSManagedObjectMergeError) {
NSArray *conflictList = [[error userInfo] objectForKey:@"conflictList"];
NSEnumerator *e = [conflictList objectEnumerator];
NSDictionary *conflict;
while (conflict=[e nextObject]) {
[self refreshObject:[conflict objectForKey:@"object"] mergeChanges:YES];
}
}
else {
// handle other error (typically validation)...
break;
}
}
When a save fails because of merging conflicts, we send
-[refreshObject:mergeChanges:YES] to all the
conflicting objects. This behaves like an
NSMergeByPropertyObjectTrumpMergePolicy for normal attributes, and
it causes extendedSnapshot to be updated with the
changes from the persistent store, so when we try to save again,
-[willSave] will merge the right values. The while
loop is necessary because a third context may save while we are
merging the chanages from context number two. If you are rapidly
changing and saving a context, the loop could run forever, so don't
do that.
This way of saving changes the semantics a bit. Some of your objects may be refreshed; that never happens with a 'normal' save. It is only necessary when you have multiple contexts editing your extended attributes, but if you don't have that, you don't need the fancy two-layered attribute storage either. If you save normally, using NSMergeByPropertyObjectTrumpMergePolicy, all your extended attributes will behave like a single attribute w.r.t. merging. The last context to save overwrites all extended attributes to whatever it thinks they should be. (Usually their values the last time the context was saved.)
Finally, let me say that I think it is really cool that Core Data lets you do stuff like this. The Cocoa design patterns that let you extend the functionality of system frameworks are awesome. Great job, Core Data team!