Migration Manager
Instead of letting a persistent store coordinator perform store migrations, you may wish to use a migration manager. Using a migration manager gives you total control over the files created during a migration and thus the flexibility to handle each aspect of a migration in your own way. One example of a benefit of using a migration manager is that you can report on the progress of a migration, which is useful for keeping the user informed (and less cranky) about a slow launch. Although most migrations should be quite fast, some large databases requiring complex changes can take a while to migrate. To keep the user interface responsive, the migration must be performed on a background thread. The user interface has to be responsive in order to provide updates to the user. The challenge is to prevent the user from attempting to use the application during the migration. This is because the data won’t be ready yet, so you don’t want the user staring at a blank screen wondering what’s going on. This is where a migration progress View Controller comes into play.
Update Grocery Dude as follows to configure a migration View Controller:
- Select Main.storyboard.
- Drag a new View Controller onto the storyboard, placing it above the existing Navigation Controller.
- Drag a new Label and Progress View onto the new View Controller.
- Position the Progress View in the center of the View Controller and then position the Label above it.
- Widen the Label and Progress View to the width of the View Controller margins, as shown on the left in Figure 3.10.
- Configure the Label with Centered text that reads Migration Progress 0%, as shown on the left in Figure 3.10.
- Configure the Progress View progress to 0.
- Set the Storyboard ID of the View Controller to migration using Identity Inspector (Option+⌘+3) while the View Controller is selected.
- Click Editor > Resolve Auto Layout Issues > Reset to Suggested Constraints in View Controller. Figure 3.10 shows the expected result.
Figure 3.10 Migration View Controller
The new migration View Controller has UILabel and UIProgressView interface elements that will need updating during a migration. This means a way to refer to these interface elements in code is required. A new UIViewController subclass called MigrationVC will be created for this purpose.
Update Grocery Dude as follows to add a MigrationVC class in a new group:
- Right-click the existing Grocery Dude group and then select New Group.
- Set the new group name to Grocery Dude View Controllers.
- Select the Grocery Dude View Controllers group.
- Click File > New > File....
- Create a new iOS > Cocoa Touch > Objective-C class and then click Next.
- Set Subclass of to UIViewController and Class name to MigrationVC and then click Next.
- Ensure the Grocery Dude target is ticked and then create the class in the Grocery Dude project directory.
- Select Main.storyboard.
- Set the Custom Class of the new migration View Controller to MigrationVC using Identity Inspector (Option+⌘+3) while the View Controller is selected. This is in the same place as where the Storyboard ID was set.
- Show the Assistant Editor by clicking View > Assistant Editor > Show Assistant Editor (or pressing Option+⌘+Return).
- Ensure the Assistant Editor is automatically showing MigrationVC.h. The top-right of Figure 3.11 shows what this looks like. If you need to, just click Manual or Automatic while the migration View Controller is selected and select MigrationVC.h.
- Hold down Control while dragging a line from the migration progress label to the code in MigrationVC.h before @end. When you let go of the left mouse button, a pop-up will appear. In the pop-up, set the Name of the new UILabel property to label and ensure the Storage is set to Strong before clicking Connect. Figure 3.11 shows the intended configuration.
- Repeat the technique in step 12 to create a linked UIProgressView property from the progress view called progressView.
Figure 3.11 Creating storyboard-linked properties to MigrationVC.h
To report migration progress, a pointer to the migration View Controller is required in CoreDataHelper.h.
Update Grocery Dude as follows to add a new property:
- Show the Standard Editor by clicking View > Standard Editor > Show Standard Editor (or pressing ⌘+Return).
- Add #import "MigrationVC.h" to the top of CoreDataHelper.h.
- Add @property (nonatomic, retain) MigrationVC *migrationVC; to CoreDataHelper.h beneath the existing properties.
To handle migrations manually, you’ll need to work out whether a migration is necessary each time the application is launched. To make this determination, you’ll need to know the URL of the store you’re checking to see that it actually exists. Providing that it does, you then compare the store’s model metadata to the new model. The result of this model comparison is used to determine whether the new model is compatible with the existing store. If it’s not, migration is required. The isMigrationNecessaryForStore method shown in Listing 3.5 demonstrates how these checks translate into code.
Listing 3.5 CoreDataHelper.m: isMigrationNecessaryForStore
#pragma mark - MIGRATION MANAGER - (BOOL)isMigrationNecessaryForStore:(NSURL*)storeUrl { if (debug==1) { NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd)); } if (![[NSFileManager defaultManager] fileExistsAtPath:[self storeURL].path]) { if (debug==1) {NSLog(@"SKIPPED MIGRATION: Source database missing.");} return NO; } NSError *error = nil; NSDictionary *sourceMetadata = [NSPersistentStoreCoordinator metadataForPersistentStoreOfType:NSSQLiteStoreType URL:storeUrl error:&error]; NSManagedObjectModel *destinationModel = _coordinator.managedObjectModel; if ([destinationModel isConfiguration:nil compatibleWithStoreMetadata:sourceMetadata]) { if (debug==1) { NSLog(@"SKIPPED MIGRATION: Source is already compatible");} return NO; } return YES; }
Update Grocery Dude as follows to implement a new MIGRATION MANAGER section:
- Add the code from Listing 3.5 to the bottom of CoreDataHelper.m before @end.
Provided migration is necessary, the next step is to perform migration. Migration is a three-step process, as shown by the comments in Listing 3.6.
Listing 3.6 CoreDataHelper.m: migrateStore
- (BOOL)migrateStore:(NSURL*)sourceStore { if (debug==1) { NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd)); } BOOL success = NO; NSError *error = nil; // STEP 1 - Gather the Source, Destination and Mapping Model NSDictionary *sourceMetadata = [NSPersistentStoreCoordinator metadataForPersistentStoreOfType:NSSQLiteStoreType URL:sourceStore error:&error]; NSManagedObjectModel *sourceModel = [NSManagedObjectModel mergedModelFromBundles:nil forStoreMetadata:sourceMetadata]; NSManagedObjectModel *destinModel = _model; NSMappingModel *mappingModel = [NSMappingModel mappingModelFromBundles:nil forSourceModel:sourceModel destinationModel:destinModel]; // STEP 2 - Perform migration, assuming the mapping model isn't null if (mappingModel) { NSError *error = nil; NSMigrationManager *migrationManager = [[NSMigrationManager alloc] initWithSourceModel:sourceModel destinationModel:destinModel]; [migrationManager addObserver:self forKeyPath:@"migrationProgress" options:NSKeyValueObservingOptionNew context:NULL]; NSURL *destinStore = [[self applicationStoresDirectory] URLByAppendingPathComponent:@"Temp.sqlite"]; success = [migrationManager migrateStoreFromURL:sourceStore type:NSSQLiteStoreType options:nil withMappingModel:mappingModel toDestinationURL:destinStore destinationType:NSSQLiteStoreType destinationOptions:nil error:&error]; if (success) { // STEP 3 - Replace the old store with the new migrated store if ([self replaceStore:sourceStore withStore:destinStore]) { if (debug==1) { NSLog(@"SUCCESSFULLY MIGRATED %@ to the Current Model", sourceStore.path);} [migrationManager removeObserver:self forKeyPath:@"migrationProgress"]; } } else { if (debug==1) {NSLog(@"FAILED MIGRATION: %@",error);} } } else { if (debug==1) {NSLog(@"FAILED MIGRATION: Mapping Model is null");} } return YES; // indicates migration has finished, regardless of outcome }
STEP 1 involves gathering the things you need to perform a migration, which are as follows:
- A source model, which you get from the metadata of a persistent store through its coordinator via metadataForPersistentStoreOfType
- A destination model, which is just the existing _model instance variable
- A mapping model, which is determined automatically by passing nil as the bundle along with the source and destination models
STEP 2 is the process of the actual migration. An instance of NSMigrationManager is created using the source and destination models. Before migrateStoreFromURL is called, a destination store is set. This destination store is just a temporary store that’s only used for migration purposes.
STEP 3 is only triggered when a migration has succeeded. The replaceStore method is used to clean up after a successful migration. When migration occurs, a new store is created at the destination; yet, this is no good to Core Data until the migrated store has the same location and filename as the old store. In order to use the newly migrated store, the old store is deleted and the new store is put in its place. In your own projects you may wish to copy the old store to a backup location first. The option to keep a store backup is up to you and would require slightly modified code in the replaceStore method. If you do decide to back up the old store, be aware that you’ll double your application’s storage requirements in the process.
The migration process is made visible to the user by an observeValueForKeyPath method that is called whenever the migration progress changes. This method is responsible for updating the migration progress View Controller whenever it sees a change to the migrationProgress property of the migration manager.
The code involved in the observeValueForKeyPath and replaceStore methods is shown in Listing 3.7.
Listing 3.7 CoreDataHelper.m: observeValueForKeyPath and replaceStore
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"migrationProgress"]) { dispatch_async(dispatch_get_main_queue(), ^{ float progress = [[change objectForKey:NSKeyValueChangeNewKey] floatValue]; self.migrationVC.progressView.progress = progress; int percentage = progress * 100; NSString *string = [NSString stringWithFormat:@"Migration Progress: %i%%", percentage]; NSLog(@"%@",string); self.migrationVC.label.text = string; }); } } - (BOOL)replaceStore:(NSURL*)old withStore:(NSURL*)new { BOOL success = NO; NSError *Error = nil; if ([[NSFileManager defaultManager] removeItemAtURL:old error:&Error]) { Error = nil; if ([[NSFileManager defaultManager] moveItemAtURL:new toURL:old error:&Error]) { success = YES; } else { if (debug==1) {NSLog(@"FAILED to re-home new store %@", Error);} } } else { if (debug==1) { NSLog(@"FAILED to remove old store %@: Error:%@", old, Error); } } return success; }
Update Grocery Dude as follows to continue implementing the MIGRATION MANAGER section:
- Add the code from Listing 3.7 and then Listing 3.6 to the MIGRATION MANAGER section at the bottom of CoreDataHelper.m before @end.
To start a migration in the background using a migration manager, the method shown in Listing 3.8 is needed.
Listing 3.8 CoreDataHelper.m: performBackgroundManagedMigrationForStore
- (void)performBackgroundManagedMigrationForStore:(NSURL*)storeURL { if (debug==1) { NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd)); } // Show migration progress view preventing the user from using the app UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil]; self.migrationVC = [sb instantiateViewControllerWithIdentifier:@"migration"]; UIApplication *sa = [UIApplication sharedApplication]; UINavigationController *nc = (UINavigationController*)sa.keyWindow.rootViewController; [nc presentViewController:self.migrationVC animated:NO completion:nil]; // Perform migration in the background, so it doesn't freeze the UI. // This way progress can be shown to the user dispatch_async( dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ BOOL done = [self migrateStore:storeURL]; if(done) { // When migration finishes, add the newly migrated store dispatch_async(dispatch_get_main_queue(), ^{ NSError *error = nil; _store = [_coordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:[self storeURL] options:nil error:&error]; if (!_store) { NSLog(@"Failed to add a migrated store. Error: %@", error);abort();} else { NSLog(@"Successfully added a migrated store: %@", _store);} [self.migrationVC dismissViewControllerAnimated:NO completion:nil]; self.migrationVC = nil; }); } }); }
The performBackgroundManagedMigrationForStore method uses a storyboard identifier to instantiate and present the migration view. Once the view is blocking user interaction, the migration can begin. The migrateStore method is called on a background thread. Once migration is complete, the coordinator then adds the store as usual, the migration view is dismissed, and normal use of the application can resume.
Update Grocery Dude as follows to continue implementing the MIGRATION MANAGER section:
- Add the code from Listing 3.8 to the MIGRATION MANAGER section at the bottom of CoreDataHelper.m before @end.
The best time to check whether migration is necessary is just before a store is added to a coordinator. To orchestrate this, the loadStore method of CoreDataHelper.m needs to be updated. If a migration is necessary, it will be triggered here. Listing 3.9 shows the code involved.
Listing 3.9 CoreDataHelper.m: loadStore
if (debug==1) { NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd)); } if (_store) {return;} // Don’t load store if it’s already loaded BOOL useMigrationManager = YES; if (useMigrationManager && [self isMigrationNecessaryForStore:[self storeURL]]) { [self performBackgroundManagedMigrationForStore:[self storeURL]]; } else { NSDictionary *options = @{ NSMigratePersistentStoresAutomaticallyOption:@YES ,NSInferMappingModelAutomaticallyOption:@NO ,NSSQLitePragmasOption: @{@"journal_mode": @"DELETE"} }; NSError *error = nil; _store = [_coordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:[self storeURL] options:options error:&error]; if (!_store) { NSLog(@"Failed to add store. Error: %@", error);abort(); } else {NSLog(@"Successfully added store: %@", _store);} }
Update Grocery Dude as follows to finalize the Migration Manager:
- Replace all existing code in the loadStore method of CoreDataHelper.m with the code from Listing 3.9.
- Add a model version called Model 4 based on Model 3.
- Select Model 4.xcdatamodel.
- Delete the Amount entity.
- Add a new entity called Unit with a String attribute called name.
- Create an NSManagedObject subclass of the Unit entity. When it comes time to save the class file, don’t forget to tick the Grocery Dude target.
- Set Model 4 as the current model.
- Create a new mapping model with Model 3 as the source and Model 4 as the target. When it comes time to save the mapping model file, don’t forget to tick the Grocery Dude target and save the mapping model as Model3toModel4.
- Select Model3toModel4.xcmappingmodel.
- Select the Unit entity mapping.
- Set the Source of the Unit entity to Amount and the Value Expression of the name destination attribute to $source.xyz. You should see the Unit entity mapping automatically renamed to AmountToUnit, as shown in Figure 3.12.
Figure 3.12 Mapping model for AmountToUnit
You’re almost ready to perform a migration; however, the fetch request in the demo method still refers to the old Amount entity.
Update Grocery Dude as follows to refer to the Unit entity instead of the Amount entity:
- Replace #import "Amount.h" with #import "Unit.h" at the top of AppDelegate.m.
- Replace the code in the demo method of AppDelegate.m with the code shown in Listing 3.10. This code just fetches 50 Unit objects from the persistent store.
Listing 3.10 AppDelegate.m: demo (Fetching Test Unit Data)
NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Unit"]; [request setFetchLimit:50]; NSError *error = nil; NSArray *fetchedObjects = [_coreDataHelper.context executeFetchRequest:request error:&error]; if (error) {NSLog(@"%@", error);} else { for (Unit *unit in fetchedObjects) { NSLog(@"Fetched Object = %@", unit.name); } }
The migration manager is finally ready! Run the application and pay close attention! You should see the migration manager flash before your eyes, alerting you to the progress of the migration. The progress will also be shown in the console log.
Figure 3.13 Visible migration progress
Examine the contents of the ZUNIT table in the Grocery-Dude.sqlite file using the techniques discussed in Chapter 2. The expected result is shown in Figure 3.14. If you notice a -wal file in the Stores directory and you’re sure that the default journaling mode is disabled, you might need to click Product > Clean and run the application again to examine the contents of the sqlite file.
Figure 3.14 Successful use of the Migration Manager
If you’ve reproduced the results shown in Figure 3.14, give yourself a pat on the back because you’ve successfully implemented three types of model migration! The rest of the book will use lightweight migrations, so it needs to be re-enabled.
Update Grocery Dude as follows to re-enable lightweight migration:
- Set the NSInferMappingModelAutomaticallyOption option in the loadStore method of CoreDataHelper.m to @YES.
- Set useMigrationManager to NO in the loadStore method of CoreDataHelper.m.
- Remove all code from the demo method of AppDelegate.m.
The old mapping models and NSManagedObject subclasses of entities that don’t exist anymore are no longer needed. Although you could remove them, leave them in the project for reference sake.