Migration Manager
Instead of letting a persistent store coordinator perform store migrations, you may want to manually migrate stores using an instance of NSMigrationManager. Using a migration manager still uses a mapping model; however, the difference is you have total control over the migration and the ability to report progress. To be certain that migration is being handled manually, automatic migration should be disabled.
Update Groceries as follows to disable automatic migration:
- Set the NSMigratePersistentStoresAutomaticallyOption option in the localStore variable of CDHelper.swift to false by changing the 1 to a 0.
Reporting on the progress of a migration is useful for keeping the user informed (and less annoyed) about a slow launch. Although most migrations should be 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. At the same time, the user interface has to be responsive 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 comes into play.
Update Groceries 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 directly in the center of the View Controller and then position the Label above it in the center.
Widen the Label and Progress View to the width of the View Controller margins, as shown in the center of Figure 3.10.
Figure 3.10 Migration View Controller
- Configure the Label with Centered text that reads Migration Progress 0%, as shown in the center of Figure 3.10.
- Configure the Progress View progress to 0.
- Select the View Controller and set its Storyboard ID to migration using Identity Inspector (Option++3).
Optionally configure the following layout constraints by holding down the control key and dragging from the progress bar toward the applicable margin. You may skip this step if you’re uncomfortable with constraints as it is not critical.
- Leading Space to Container Margin
- Trailing Space to Container Margin
- Center Vertically In Container
Optionally configure the following layout constraints by holding down the control key and dragging from the progress label toward the applicable margin. You may skip this step if you’re uncomfortable with constraints as it is not critical.
- Leading Space to Container Margin
- Trailing Space to Container Margin
- Vertical Spacing from the progress bar
Introducing MigrationVC.swift (Migration View Controller Code)
The new migration View Controller has UILabel and UIProgressView interface elements that need updating during a migration. This means that a way to refer to these interface elements in code is required. A new UIViewController subclass called MigrationVC should be created for this purpose.
Update Groceries as follows to add a MigrationVC file to a new group:
- Right-click the existing Groceries group and then select New Group.
- Set the new group name to View Controllers. This group will contain all of the view controllers. As a side note, feel free to move ViewController.swift to the trash because it is no longer required.
- Select the View Controllers group.
- Click File > New > File....
- Create a new iOS > Source > Cocoa Touch Class and then click Next.
- Set the subclass to UIViewController and the filename to MigrationVC.
- Ensure the language is Swift and then click Next.
- Ensure the Groceries target is checked and that the new file will be saved in the Groceries project directory; then click Create.
- 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.swift. 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.swift.
Figure 3.11 Creating storyboard-linked properties to MigrationVC.swift
- Hold down the control key while dragging a line from the migration progress label to the code in MigrationVC.swift on the line before the viewDidLoad function. When you let go of the mouse button, a pop-up appears. In the pop-up, set the Name 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 13 to create a linked UIProgressView variable from the progress view called progressView.
There should now be an @IBOutlet called label and an @IBOutlet called progressView in MigrationVC.swift. You may now switch back to the Standard Editor ( + return).
When a migration occurs, notifications that communicate progress need to be sent. For the progress bar to reflect the progress, a new function is required in MigrationVC.swift. In addition, this function needs to be called every time a progress update is observed. Listing 3.5 shows the new code involved in bold.
Listing 3.5 Migration View Controller (MigrationVC.swift)
import UIKit class MigrationVC: UIViewController { @IBOutlet var label: UILabel! @IBOutlet var progressView: UIProgressView! // MARK: - MIGRATION func progressChanged (note:AnyObject?) { if let _note = note as? NSNotification { if let progress = _note.object as? NSNumber { let progressFloat:Float = round(progress.floatValue * 100) let text = "Migration Progress: \(progressFloat)%" print(text) dispatch_async(dispatch_get_main_queue(), { self.label.text = text self.progressView.progress = progress.floatValue }) } else {print("\(__FUNCTION__) FAILED to get progress")} } else {print("\(__FUNCTION__) FAILED to get note")} } override func viewDidLoad() { super.viewDidLoad() NSNotificationCenter.defaultCenter().addObserver(self, selector: "progressChanged:", name: "migrationProgress", object: nil) } deinit { NSNotificationCenter.defaultCenter().removeObserver(self, name: "migrationProgress", object: nil) } }
The progressChanged function simply unwraps the progress notification and constructs a string with the migration completion percentage. It then updates the user interface with this information. Of course, none of this can happen without first adding an observer of the migrationProgress variable in the viewDidLoad function. When the view deinitializes, it is unregistered as an observer of the migrationProgress variable.
Update Groceries as follows to ensure migration progress is reported to the user:
- Replace all code in MigrationVC.swift with the code from Listing 3.5.
The user interface is now positioned to report migration progress to the user. The next step is to implement the code required to perform a manual migration.
Introducing CDMigration.swift (Core Data Migration Code)
To keep CDHelper.swift small, the code required to perform a managed migration is put in a new class called CDMigration.swift. The starting point to this class is shown in Listing 3.6.
Listing 3.6 Migration View Controller Shared Instance (CDMigration.swift shared)
import UIKit import CoreData private let _sharedCDMigration = CDMigration() class CDMigration: NSObject { // MARK: - SHARED INSTANCE class var shared : CDMigration { return _sharedCDMigration } }
Just like CDHelper.swift, CDMigration.swift has a shared function that makes it easy to use because you can call it from anywhere in the project via CDMigration.shared.
Update Groceries as follows to implement CDMigration.swift:
- Select the Generic Core Data Classes group.
- Click File > New > File....
- Create a new iOS > Source > Swift File and then click Next.
- Set the filename to CDMigration and ensure the Groceries target is checked.
- Ensure the Groceries project directory is open and then click Create.
- Replace the contents of CDMigration.swift with the code from Listing 3.6.
To handle migrations manually, three supporting functions are required. One function checks that a given store exists and another checks that it needs migrating. A successful migration generates a separate compatible store, so as soon as migration completes, this new store needs to replace the incompatible one. The final supporting function does exactly that—it replaces the incompatible store with the migrated store. Listing 3.7 shows the code involved with these three supporting functions.
Listing 3.7 Migration View Controller Supporting Functions (CDMigration.swift storeExistsAtPath, store, replaceStore)
// MARK: - SUPPORTING FUNCTIONS func storeExistsAtPath(storeURL:NSURL) -> Bool { if let _storePath = storeURL.path { if NSFileManager.defaultManager().fileExistsAtPath(_storePath) { return true } } else {print("\(__FUNCTION__) FAILED to get store path")} return false } func store(storeURL:NSURL, isCompatibleWithModel model:NSManagedObjectModel) -> Bool { if self.storeExistsAtPath(storeURL) == false { return true // prevent migration of a store that does not exist } do { var _metadata:[String : AnyObject]? _metadata = try NSPersistentStoreCoordinator.metadataForPersistentStoreOfType(NSSQLiteStoreType, URL: storeURL, options: nil) if let metadata = _metadata { if model.isConfiguration(nil, compatibleWithStoreMetadata: metadata) { print("The store is compatible with the current version of the model") return true } } else {print("\(__FUNCTION__) FAILED to get metadata")} } catch { print("ERROR getting metadata from \(storeURL) \(error)") } print("The store is NOT compatible with the current version of the model") return false } func replaceStore(oldStore:NSURL, newStore:NSURL) throws { let manager = NSFileManager.defaultManager() do { try manager.removeItemAtURL(oldStore) try manager.moveItemAtURL(newStore, toURL: oldStore) } }
The storeExistsAtPath function uses NSFileManager to determine whether a store exists at the given URL. It returns a Bool indicating the result.
The store:isCompatibleWithModel function first checks that a store exists at the given path. If there is no store, true is returned because this prevents a migration from being attempted. If a store exists at the given URL, it is checked for model compatibility against the given model. To do this, the model used to create the store is drawn from the store’s metadata and then compared to the given model via its isConfiguration:compatibleWithStoreMetadata function.
The replaceStore function uses NSFileManager to remove the incompatible store from the file system and then replaces it with the compatible store.
Update Groceries as follows to implement a new SUPPORTING FUNCTIONS section:
- Add the code from Listing 3.7 to the bottom of CDMigration.swift before the last curly brace.
When a migration is in progress, the value of the migration manager’s migrationProgress variable is constantly updated. This is information that the user needs to see, so a function is required to react whenever the migrationProgress value changes. Listing 3.8 shows a new function that posts a notification whenever this value changes.
Listing 3.8 Migration View Controller Progress Reporting (CDMigration.swift observeValueForKeyPath)
// MARK: - PROGRESS REPORTING override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { if object is NSMigrationManager, let manager = object as? NSMigrationManager { if let notification = keyPath { NSNotificationCenter.defaultCenter().postNotificationName(notification, object: NSNumber(float: manager.migrationProgress)) } } else {print("observeValueForKeyPath did not receive a NSMigrationManager class")} }
Update Groceries as follows to implement a new PROGRESS REPORTING section:
- Add the code from Listing 3.8 to the bottom of CDMigration.swift before the last curly brace.
The next function is where the actual migration happens. Most of this function is used to gather all the pieces required to perform a migration. Listing 3.9 shows the code involved.
Listing 3.9 Migration (CDMigration.swift migrateStore)
// MARK: - MIGRATION func migrateStore(store:NSURL, sourceModel:NSManagedObjectModel, destinationModel:NSManagedObjectModel) { if let tempdir = store.URLByDeletingLastPathComponent { let tempStore = tempdir.URLByAppendingPathComponent("Temp.sqlite") let mappingModel = NSMappingModel(fromBundles: nil, forSourceModel: sourceModel, destinationModel: destinationModel) let migrationManager = NSMigrationManager(sourceModel: sourceModel, destinationModel: destinationModel) migrationManager.addObserver(self, forKeyPath: "migrationProgress", options: NSKeyValueObservingOptions.New, context: nil) do { try migrationManager.migrateStoreFromURL(store, type: NSSQLiteStoreType, options: nil,withMappingModel: mappingModel, toDestinationURL: tempStore, destinationType: NSSQLiteStoreType, destinationOptions: nil) try replaceStore(store, newStore: tempStore) print("SUCCESSFULLY MIGRATED \(store) to the Current Model") } catch { print("FAILED MIGRATION: \(error)") } migrationManager.removeObserver(self, forKeyPath: "migrationProgress") } else {print("\(__FUNCTION__) FAILED to prepare temporary directory")} }
The migrateStore function needs to be given a store to migrate, a source model to migrate from, and destination model to migrate to. The source model could have been taken from the given store’s metadata; however, seeing as this step is performed first in another function, this approach saves repeated code.
The first thing migrateStore does is prepare four variables:
- The tempdir variable holds the URL to the given store and is used to build a URL to a temporary store used for migration.
- The tempStore variable holds the URL to the temporary store used for migration.
- The mappingModel variable holds an instance of NSMappingModel specific to the models being migrated from and to. The migration will fail without a mapping model.
- The migrationManager variable holds an instance of NSMigrationManager based on the source and destination models. An observer is added for the migrationProgress variable so that the observeValueForKeyPath function is called whenever the migrationProgress variable changes.
All these variables are then used to make a call to the migrateStoreFromURL function, which is responsible for migrating the given store to be compatible with the destination model. Once this is complete, the old incompatible store is removed and the new compatible store is put in its place.
Update Groceries as follows to implement a new MIGRATION section:
- Add the code from Listing 3.9 to the bottom of CDMigration.swift before the final closing curly brace.
The migration code that has just been implemented needs to be called from a background thread so that the user interface can be updated without freezing. This, along with the instantiation of the progress view that the user sees, is shown in Listing 3.10.
Listing 3.10 Migration Progress (CDMigration.swift migrateStoreWithProgressUI)
func migrateStoreWithProgressUI(store:NSURL, sourceModel:NSManagedObjectModel, destinationModel:NSManagedObjectModel) { // Show migration progress view preventing the user from using the app let storyboard = UIStoryboard(name: "Main", bundle: nil) if let initialVC = UIApplication.sharedApplication().keyWindow?.rootViewController as? UINavigationController { if let migrationVC = storyboard.instantiateViewControllerWithIdentifier("migration") as? MigrationVC { initialVC.presentViewController(migrationVC, animated: false, completion: { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), { print("BACKGROUND Migration started...") self.migrateStore(store, sourceModel: sourceModel, destinationModel: destinationModel) dispatch_async(dispatch_get_main_queue(), { // trigger the stack setup again, this time with the upgraded store let _ = CDHelper.shared.localStore dispatch_after(2, dispatch_get_main_queue(), { migrationVC.dismissViewControllerAnimated(false, completion: nil) }) }) }) }) } else {print("FAILED to find a view controller with a story board id of 'migration'")} } else {print("FAILED to find the root view controller, which is supposed to be a navigation controller")} }
The migrateStoreWithProgressUI function uses a storyboard identifier to instantiate and present the migration view. Once the view is blocking user interaction the migration can begin. The migrateStore function is called on a background thread. Once migration is complete, the localStore is loaded as usual, the migration view is dismissed, and normal use of the application can resume.
Update Groceries as follows to implement the migrateStoreWithProgressUI function:
- Add the code from Listing 3.10 to the MIGRATION section at the bottom of CDMigration.swift before the last curly brace.
The final piece of code required in CDMigration.swift is used to migrate the store if necessary. This function is called from the setupCoreData function of CDHelper.swift, which is run as a part of initialization. Listing 3.11 shows the code involved.
Listing 3.11 Migration (CDMigration.swift migrateStoreIfNecessary)
func migrateStoreIfNecessary (storeURL:NSURL, destinationModel:NSManagedObjectModel) { if storeExistsAtPath(storeURL) == false { return } if store(storeURL, isCompatibleWithModel: destinationModel) { return } do { var _metadata:[String : AnyObject]? _metadata = try NSPersistentStoreCoordinator.metadataForPersistentStoreOfType(NSSQLiteStoreType, URL: storeURL, options: nil) if let metadata = _metadata, let sourceModel = NSManagedObjectModel.mergedModelFromBundles([NSBundle.mainBundle()], forStoreMetadata: metadata) { self.migrateStoreWithProgressUI(storeURL, sourceModel: sourceModel, destinationModel: destinationModel) } } catch { print("\(__FUNCTION__) FAILED to get metadata \(error)") } }
Once it’s established that the given store exists, a model compatibility check is performed and the store is migrated if necessary. The model used to create the given store is drawn from the store’s metadata. This is then given to the migrateStoreWithProgressUI function.
Update Groceries as follows to implement the migrateStoreIfNecessary function:
- Add the code from Listing 3.11 to MIGRATION section at the bottom of CDMigration.swift before the last curly brace.
When CDHelper.swift initializes, a call is made to setupCoreData. This is an ideal time to check that the localStore is compatible with the current model, before it’s needed. The new code required in the setupCoreData is shown in bold in Listing 3.12.
Listing 3.12 Migration During Setup (CDHelper.swift setupCoreData)
func setupCoreData() { // Model Migration if let _localStoreURL = self.localStoreURL { CDMigration.shared.migrateStoreIfNecessary(_localStoreURL, destinationModel: self.model) } // Load Local Store _ = self.localStore }
Update Groceries as follows to ensure a manual migration is triggered as required:
- Replace the setupCoreData function of CDHelper.swift with the code from Listing 3.12.
Currently the localStore variable of CDHelper.swift always tries to return a store. If it tried to return a store that wasn’t compatible with the current model, the application would throw an error. To prevent this, a check is needed to see whether the store needs migrating before it is loaded. This check is needed only when migration is handled manually, so the bold code in Listing 3.13 wouldn’t be required otherwise.
Listing 3.13 Triggering Migration Manager (CDHelper.swift localStore)
lazy var localStore: NSPersistentStore? = { let useMigrationManager = true if let _localStoreURL = self.localStoreURL { if useMigrationManager == true && CDMigration.shared.storeExistsAtPath(_localStoreURL) && CDMigration.shared.store(_localStoreURL, isCompatibleWithModel: self.model) == false { return nil // Don't return a store if it's not compatible with the model } } let options:[NSObject:AnyObject] = [NSSQLitePragmasOption:["journal_mode":"DELETE"], NSMigratePersistentStoresAutomaticallyOption:0, NSInferMappingModelAutomaticallyOption:0] var _localStore:NSPersistentStore? do { _localStore = try self.coordinator.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: self.localStoreURL, options: options) return _localStore } catch { return nil } }()
Update Groceries as follows to ensure a localStore is not returned when the migration manager is used and a manual migration is required:
- Replace the localStore variable in CDHelper.swift with the code from Listing 3.13.
For progress to be shown to the user, the interface needs to be ready before a migration is triggered. This means that the first call to anything Core Data related should be made from an existing view after it has loaded. To demonstrate the migration process, a small amount of code needs to be applied to the existing table view. The only table view in the application so far is the Prepare table view, where the user adds items to the shopping list. Listing 3.14 shows the minimal code involved that triggers a store model migration. Note that the table view won’t be configured to display anything until later in the book.
Listing 3.14 The Prepare Table View Controller (PrepareTVC.swift)
import UIKit class PrepareTVC: UITableViewController { override func viewDidLoad() { super.viewDidLoad() } override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) // Trigger Demo Code if let appDelegate = UIApplication.sharedApplication().delegate as? AppDelegate { appDelegate.demo() } } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() } override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 0 } override func numberOfSectionsInTableView(tableView: UITableView) -> Int { return 0 } }
The PrepareTVC.swift code is the bare minimum code required to show a table view. You might notice that the view is configured to return no rows or sections, as the intent for the moment is just to show model migration progress.
Update Groceries as follows to implement PrepareTVC.swift:
- Right-click the existing Groceries group and then select New Group.
- Set the new group name to Table View Controllers.
- Select the Table View Controllers group.
- Click File > New > File....
- Create a new iOS > Source > Swift File and then click Next.
- Set the filename to PrepareTVC and ensure the Groceries target is checked.
- Ensure the Groceries project directory is open and then click Create.
- Replace the contents of PrepareTVC.swift with the code from Listing 3.14.
- Select Main.storyboard.
- Set the Custom Class of the Table View Controller to PrepareTVC using Identity Inspector (Option++3) while the Table View Controller is selected.
- Remove the call to CDHelper.shared from the application:didFinishLaunchingWithOptions function of AppDelegate.swift. This code would otherwise trigger a migration before the user interface was ready.
- Remove the call to demo() from the application:applicationDidBecomeActive function of AppDelegate.swift. This code would otherwise trigger a migration before the user interface was ready.
Almost everything is in place to perform a manual migration; however, a new managed object model and mapping model are required to show what attributes map to where.
Update Groceries as follows to prepare the new model:
- Add a model version called Model 4 based on Model 3.
- Set Model 4 as the current model.
- Select Model 4.xcdatamodel.
- Delete the Amount entity.
- Add a new entity called Unit with a String attribute called name.
- Set the default value of the name attribute to New Unit.
- Create an NSManagedObject subclass of the Unit entity. When it comes time to save the class file, don’t forget to check the Groceries target and ensure that the Data Model group is selected.
- 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 check the Groceries 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 function still refers to the old Amount entity. Listing 3.15 shows an updated version of this function.
Listing 3.15 Fetching Test Unit Data (AppDelegate.swift demo)0
func demo () { let context = CDHelper.shared.context let request = NSFetchRequest(entityName: "Unit") request.fetchLimit = 50 do { if let units = try context.executeFetchRequest(request) as? [Unit] { for unit in units { print("Fetched Unit Object \(unit.name!)") } } } catch { print("ERROR executing a fetch request: \(error)") } }
Update Groceries as follows to refer to the Unit entity instead of the Amount entity:
- Replace the demo function of AppDelegate.swift with the code shown in Listing 3.15. This code just fetches 50 Unit objects from the persistent store.
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 is also shown in the console log (see Figure 3.13).
Figure 3.13 Visible migration progress
Examine the contents of the ZUNIT table in the LocalStore.sqlite file using the techniques discussed in Chapter 2. The expected result is shown in Figure 3.14.
Figure 3.14 Successful use of migration manager
If you reproduced the results shown in Figure 3.14, give yourself a pat on the back because you successfully implemented three types of model migration! The rest of the book uses lightweight migrations, so it needs to be re-enabled. Before you continue, close the SQLite Database Browser.
Update Groceries as follows to re-enable lightweight migration:
- Set useMigrationManager to false in the localStore variable of CDHelper.swift.
- Set the NSMigratePersistentStoresAutomaticallyOption option in the localStore variable of CDHelper.swift to true by changing the 0 to a 1.
- Set the NSInferMappingModelAutomaticallyOption option in the localStore variable of CDHelper.swift to true by changing the 0 to a 1.
- Comment out the call to migrateStoreIfNecessary from the setupCoreData function of CDHelper.swift.
- Replace the code in the demo function of AppDelegate.swift with a call to CDHelper.shared. This ensures that the Core Data stack is set up without a reliance on particular entities.
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.