In this post we'll see about implementing the NSEntityMigrationPolicy subclass code for converting existing currency double values into decimal values.
According to Apple's Core Data Model Versioning and Data Migration Programming Guide, the migration process happens in three steps.
1. Create destination entities from source entities. In this step only attributes are transferred to the destination entity from the source.
2. Recreate relationships.
3. Validate and save. This step validates all rules set in the model to ensure data integrity.
The step that I customized was the first step. This step is implemented in the NSEntityMigrationPolicy method createDestinationInstancesForSourceInstance:entityMapping:manager:error:
- (BOOL) createDestinationInstancesForSourceInstance:(NSManagedObject *)sInstance entityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager *)manager error:(NSError *__autoreleasing *)error { NSManagedObjectContext *destMoc = [manager destinationContext]; NSString *destEntityName = [mapping destinationEntityName]; // create the destination entity NSManagedObject *destEntity = [NSEntityDescription insertNewObjectForEntityForName:destEntityName inManagedObjectContext:destMoc]; // get all attribute keys of the source entity NSArray *sourceAttribKeys = [[[sInstance entity] attributesByName] allKeys]; // iterate through all the attribute keys in the source and apply their values // to the destination for (NSString *attribKey in sourceAttribKeys) { id attribValue = [sInstance valueForKey:attribKey]; [destEntity setValue:attribValue forKey:attribKey]; } // TODO: now convert the track period expense total from double to decimal and // apply that value to the destination's expense total attribute [manager associateSourceInstance:sInstance withDestinationInstance:destEntity forEntityMapping:mapping]; return YES; }
The above code manually creates the destination entity and copies all attributes except for the one called "expenseTotal." That attribute was originally a double type but needed to be changed to a decimal type. Which brought me to my next problem: the problem of conversion.
How would I convert existing currency value data, stored as a double, into a decimal number? I needed a way to take a double value, reduce its precision to two decimal places, and do so with appropriate rounding behavior. Ultimately it would be stored in an NSDecimalNumber object; unfortunately that class did not have any way to initialize itself with an existing double precision floating point.
A quick search on StackExchange yielded the following answer: NSNumberFormatter. It turns out, with an appropriately configured NSNumberFormatter, it can generate a string representation of an appropriately rounded double-precision floating point value with a given number of decimal places. Oh, and it also happens to be that NSDecimalNumber has an initWithString: (NSString*) numericString method.
So I quickly created a project to test the algorithm and see if my NSNumberFormatter could do the job appropriately. Below is a screenshot of the code in action, with a breakpoint at a particularly notable section of the code:
Notice that the rounding mode is set to NSNumberFormatterRoundHalfUp. This is the rounding most of us were taught in school. If rounding to N decimal places, and the N+1 digit is 5 or greater, round the N digit up one. Otherwise round the N digit down one.
Also notice at the breakpoint how the 56.78888 value is actually being stored. This goes to show the inherent imprecision in floats and doubles and one can start to see where rounding errors can become introduced during math operations. Particularly nasty when doing so to currency values.
So now that I knew the algorithm would work, here is the NSEntityMigrationPolicy subclass in its entirety:
@implementation SduTrackingPeriodMigration016To023 { NSNumberFormatter *_formatter; } - (BOOL) beginEntityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager *)manager error:(NSError *__autoreleasing *)error { // set up the NSNumberFormatter _formatter = [[NSNumberFormatter alloc] init]; [_formatter setNumberStyle:NSNumberFormatterDecimalStyle]; [_formatter setMaximumFractionDigits:2]; [_formatter setRoundingMode:NSNumberFormatterRoundHalfUp]; [_formatter setUsesGroupingSeparator:NO]; return YES; } - (BOOL) createDestinationInstancesForSourceInstance:(NSManagedObject *)sInstance entityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager *)manager error:(NSError *__autoreleasing *)error { NSManagedObjectContext *destMoc = [manager destinationContext]; NSString *destEntityName = [mapping destinationEntityName]; // create the destination entity NSManagedObject *destEntity = [NSEntityDescription insertNewObjectForEntityForName:destEntityName inManagedObjectContext:destMoc]; // get all attribute keys of the source entity NSArray *sourceAttribKeys = [[[sInstance entity] attributesByName] allKeys]; // iterate through all the attribute keys in the source and apply their values // to the destination for (NSString *attribKey in sourceAttribKeys) { // only copy attributes other than expenseTotal if (![attribKey isEqualToString:@"expenseTotal"]) { id attribValue = [sInstance valueForKey:attribKey]; [destEntity setValue:attribValue forKey:attribKey]; } } // now convert the expense total from double to decimal and // apply that value to the destination's expense total attribute NSNumber *value = (NSNumber *)[sInstance valueForKey:@"expenseTotal"]; double dub = [value doubleValue]; NSDecimalNumber *decimalValue = [self decimalFromDouble:dub]; [destEntity setValue:decimalValue forKeyPath:@"expenseTotal"]; // since this method is not calling super, we have to manually call // associateSourceInstance: withDestinationInstance: forEntityMapping: ourselves [manager associateSourceInstance:sInstance withDestinationInstance:destEntity forEntityMapping:mapping]; return YES; } - (NSDecimalNumber *) decimalFromDouble: (double) doubleValue { NSString *roundedString = [_formatter stringFromNumber:[NSNumber numberWithDouble:doubleValue]]; return [[NSDecimalNumber alloc] initWithString:roundedString]; } @end
An important thing to note is how I am calling usesGroupingSeparator: and passing in NO. This is is because the stringFromNumber: method call will return a grouping separator (",") as part of the string. Then feeding a string representation of a number that includes the grouping separator will make the initWithString: method of NSDecimalNumber cut off everything after, and including, the grouping separator. This is a corruption of data during the migration. Not good.
In the next post I'll show how I updated the version of the .xcdatamodeld document and specified how the Core Data persistent store should migrate itself to the newest version.
No comments:
Post a Comment