When saving an NSArray to a transformable Core Data attribute, the object will not be available for access on the subsequent fetch of its entity. However, it is available on any fetch after that. What's going on?
I can set and save the Core Data entity and its attributes from one place in my iOS app. Then I go to read the most recently saved entity. All of the attributes except the transformable NSArrays are available. For some reason the arrays show up as empty (when printed in the log it looks like this: route = "(\n)"
. If the app closes and then opens again, the attribute is no longer empty. Any ideas?
I understand that saving an NSArray to a transformable attribute is not the best practice. Could you explain why this happens?
Update 1
The NSArray is filled with CLLocation objects.
There are no errors or warnings printed in the console. Nor are their any compiler warnings or errors.
Update 2
Below is an XCTest I wrote for this issue. The test does not fail until the very last assertion (as expected).
- (void)testRouteNotNil {
// This is an example of a performance test case.
NSMutableArray *route;
for (int i = 0; i < 500; i++) {
CLLocation *location = [[CLLocation alloc] initWithLatitude:18 longitude:18];
[route addObject:location];
}
NSArray *immutableRoute = route;
// Save the workout entity
// Just use placeholder values for the XCTest
// The method below works fine, as the saved object exists when it is fetched and no error is returned.
NSError *error = [self saveNewRunWithDate:@"DATE01" time:@"TIME" totalSeconds:100 distance:[NSNumber numberWithInt:100] distanceString:@"DISTANCE" calories:@"CALORIES" averageSpeed:[NSNumber numberWithInt:100] speedUnit:@"MPH" image:[UIImage imageNamed:@"Image"] splits:route andRoute:immutableRoute];
XCTAssertNil(error);
// Fetch the most recently saved workout entity
RunDataModel *workout = [[[SSCoreDataManager sharedManager] fetchEntityWithName:@"Run" withSortAttribute:@"dateObject" ascending:NO] objectAtIndex:0];
XCTAssertNotNil(workout);
// Verify that the fetched workout is the one we just saved above
XCTAssertEqual(workout.date, @"DATE01");
// Check that the any non-NSArray object stored in the entity is not nil
XCTAssertNotNil(workout.distance);
// Check that the route object is not nil
XCTAssertNotNil(workout.route);
}
Update 3
As you can see below, this is how the Core Data model is setup in Xcode. The route attribute is selected. Note that I have tried it both with and without the transient property. Do I need to add a Value Transformer Name
, what is that?
Update 4
The Core Data management code itself comes from my GitHub repo, SSCoreDataManger (which works well to my knowledge).
Here is the saveNewRunWithDate
method:
- (NSError *)saveNewRunWithDate:(NSString *)date time:(NSString *)time totalSeconds:(NSInteger)totalSeconds distance:(NSNumber *)distance distanceString:(NSString *)distanceLabel calories:(NSString *)calories averageSpeed:(NSNumber *)speed speedUnit:(NSString *)speedUnit image:(UIImage *)image splits:(NSArray *)splits andRoute:(NSArray *)route {
RunDataModel *newRun = [[SSCoreDataManager sharedManager] insertObjectForEntityWithName:@"Run"];
newRun.date = date;
newRun.dateObject = [NSDate date];
newRun.time = time;
newRun.totalSeconds = totalSeconds;
newRun.distanceLabel = distanceLabel;
newRun.distance = distance;
newRun.calories = calories;
newRun.averageSpeed = speed;
newRun.speedUnit = speedUnit;
newRun.image = image;
newRun.splits = splits; // This is also an issue
newRun.route = route; // This is an issue
return [[SSCoreDataManager sharedManager] saveObjectContext];
}
And below is the RunDataModel
NSManagedObject Interface:
/// CoreData model for run storage with CoreData
@interface RunDataModel : NSManagedObject
@property (nonatomic, assign) NSInteger totalSeconds;
// ...
// Omitted most attribute properties because they are irrelevant to the question
// ...
@property (nonatomic, strong) UIImage *image;
/// An array of CLLocation data points in order from start to end
@property (nonatomic, strong) NSArray *route;
/// An array of split markers from the run
@property (nonatomic, strong) NSArray *splits;
@end
In the implementation these properties are setup using @dynamic
A "transformable" entity attribute is one that passes through an instance of NSValueTransformer. The name of the NSValueTransformer
class to use for a particular attribute is set in the managed object model. When Core Data accesses the attribute data it will call +[NSValueTransformer valueTransformerForName:]
to get an instance of the value transformer. Using that value transformer the NSData
persisted in the store for the entity will be transformed into an object value accessed through a property of the managed object instance.
You can read more about this in the Core Data Programming Guide section Non-Standard Persistent Attributes
By default Core Data uses the value transformer registered for the name NSKeyedUnarchiveFromDataTransformerName
and uses it in reverse to perform the transformation. This will happen if no value transformer name has been specified in the Core Data Model Editor, and is generally the behavior you want. If you want to use a different NSValueTransformer
you must register it's name in your application by calling +[NSValueTransformer setValueTransformer:forName:]
and set the string name in the model editor (or in code, which is another matter). Keep in mind the value transformer you use must support both forward and reverse transformation.
The default value transformer can turn any object that supports keyed archiving into NSData
. In your case, you have an NSArray
(actually, an NSMutableArray
, which is not good). NSArray
supports NSCoding
, but since it's a collection the objects contained within must support it as well - otherwise they cannot be archived. Luckily, CLLocation
does support NSSecureCoding
, a newer variant of NSCoding
.
You can test the transforming of an NSArray
of CLLocation
s using Core Data's transformer easily. For example:
- (void)testCanTransformLocationsArray {
NSValueTransformer *transformer = nil;
NSData *transformedData = nil;
transformer = [NSValueTransformer valueTransformerForName:NSKeyedUnarchiveFromDataTransformerName];
transformedData = [transformer reverseTransformedValue:[self locations]];
XCTAssertNotNil(transformedData, @"Transformer was not able to produce binary data");
}
I would encourage you to write tests like these for transformable attributes. It's easy to make changes to your application that are incompatible with the default transformer (such as inserting objects that do not support keyed archiving).
Using a set of tests like this I am not able to reproduce any problem with archiving an NSArray
of CLLocation
s.
There is one very important part of your question:
For some reason the arrays show up as empty (when printed in the log it looks like this: route = "(\n)". If the app closes and then opens again, the attribute is no longer empty. Any ideas?
This indicates that (at least in your application, perhaps not your test) the data is being transformed and applied to the entity in the store. When the application sets the routes
value, the array is persisted to the store - we know this because the next time the application is launched the data appears.
Typically this indicates a problem in the application when communicating changes between contexts. From the code you have posted it seems that you are using a single context, and only from the main thread - your SSCoreDataManager
singleton would not work correctly otherwise, and it is using the obsolete thread confinement concurrency model.
At the same time there are places SSCoreDataManager
is using -performBlock:
to access the single NSManagedObjectContext
. performBlock:
should only be used with contexts created with a queue concurrency type. The context being used here was created with -init
, which just wraps -initWithConcurrencyType:
and passes the value NSConfinementConcurrencyType
. Because of this, you definitely have concurrency issues in the singleton which are very likely causing some of the behavior you are seeing. You are persisting an attribute value on an entity, but later not seeing that value reflected when the property wrapping the attribute fires a fault in the managed object context.
If you are able to develop with Xcode 6.x and iOS 8, turn on Core Data concurrency debugging by passing the launch argument
-com.apple.CoreData.ConcurrencyDebug 1
To your application. This should make some of the problems here more visible to you, though just calling performBlock:
on a context created with -init
should be causing an exception to be thrown already. If your application is doing something to swallow exceptions that may be hiding this and more issues.
It's not clear from your question wether you are seeing this only when you are attempting to access routes
in the debugger, or if you are also seeing broken functionality when using it. When debugging managed objects you must be very aware of when you are firing a fault on a property value. It is possible that in this case you are seeing an empty array in the debugger only because it is being accessed in a way that is not causing a fault to fire - which would be correct behavior. From your description of other application behavior it does seem possible that this is the limit of your problem - after all, values are being persisted correctly.
Unfortunately the Core Data Programming Guide barely mentions what a fault is, and does so side by side with uniquing. Faulting is a fundamental part of Core Data - it's most of the point of using it - and has almost nothing to do with uniquing. Fortunately, several years ago the Incremental Store Programming Guide was updated with many insights into the internals of Core Data, including faulting.
Your test and singleton have other issues which are unfortunately beyond the scope of this question.
NSMutableArray *route = [NSMutableArray array];
Shouldn't you initialize your mutable array before adding objects to it? You should add a test to see if the array is nil.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With