Sam Spencer
Sam Spencer

Reputation: 8609

Core Data Transformable Attributes (NSArray) is empty

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?

enter image description here


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

Upvotes: 4

Views: 9355

Answers (5)

quellish
quellish

Reputation: 21244

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 CLLocations 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 CLLocations.

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.

Upvotes: 19

future_guy
future_guy

Reputation: 31

I had a similar problem to this, which I found really hard to resolve. In the end I did resolve it, but it wasn't the solution here that fixed it. I want to share what I found worked for the sake of anyone facing the same challenge as me.

The solution for me came from here: Core Data not saving transformable NSMutableDictionary

In my case, the problem was because I was trying to use an NSMutableArray as a transformable Core Data attribute. But I now understand that you shouldn't do that. Instead you should use an immutable array (i.e. NSArray) and then, if you need to change a value in the array, you copy the Core Data array to a local mutable array (i.e. var NSArray in Swift), make the change to the local array and then run a command to make the Core Data array equal the changed local array. Then save Core Data as normal.

As I say, my problem was similar to the one here, but it wasn't the same. And so I am not claiming that this is the solution to this problem. I am simply sharing this for others' benefit in case this helps them.

Upvotes: 1

Sam Spencer
Sam Spencer

Reputation: 8609

@quellish's answer provides information about Core Data faults and some of the nuances and trickeries which lie therein. After doing some digging, and with the help of that answer, I found a solution.

Before fetching the desired (problem) entity, refresh the NSManagedObject in the NSManagedObjectContext:

[self.managedObjectContext refreshObject:object mergeChanges:NO];

This updates the persistent properties of a managed object to use the latest values from the persistent store. It also turns the object into a fault.

Upvotes: 1

dennykim
dennykim

Reputation: 331

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.

Upvotes: 3

eofster
eofster

Reputation: 2047

The problem might be in not deleting the old store between the test runs. The object that you're checking might be not the same object that you've just added. Also make sure the transient property is not set. Transient attributes are not persisted.

Here's what might be happening in the tests.

  1. At some point you created new Run without a route and saved.
  2. During the next test run you're creating another run object with the same date DATE01.
  3. Instead of checking the route property of the object you've just created, you're doing the fetch sorted by date.
  4. All your routes have the same date, so sorting by date doesn't basically affect the sorted results.
  5. The first object of your fetch results happens to be some old object where you didn't set the routes property.

Just in case, log the newRun.route value inside the -saveNewRunWithDate:... method.

Upvotes: 1

Related Questions