Chapters

Hide chapters

Core Data by Tutorials

Seventh Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 14 chapters
Show chapters Hide chapters

6. Versioning & Migration
Written by Aaron Douglas

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You’ve seen how to design your data model and NSManagedObject subclasses in your Core Data apps. During app development, well before the ship date, thorough testing can help iron out the data model. However, changes in app usage, design or features after an app’s release will inevitably lead to changes in the data model. What do you do then?

You can’t predict the future, but with Core Data, you can migrate toward the future with every new release of your app. The migration process will update data created with a previous version of the data model to match the current data model.

This chapter discusses the many aspects of Core Data migrations by walking you through the evolution of a note-taking app’s data model.

You’ll start with a simple app with only a single entity in its data model. As you add more features and data to the app, the migrations you do in this chapter will become progressively more complex.

Let the great migration begin!

When to migrate

When is a migration necessary? The easiest answer to this common question is “when you need to make changes to the data model.”

However, there are some cases in which you can avoid a migration. If an app is using Core Data merely as an offline cache, when you update the app, you can simply delete and rebuild the data store. This is only possible if the source of truth for your user’s data isn’t in the data store. In all other cases, you’ll need to safeguard your user’s data.

That said, any time it’s impossible to implement a design change or feature request without changing the data model, you’ll need to create a new version of the data model and provide a migration path.

The migration process

When you initialize a Core Data stack, one of the steps involved is adding a store to the persistent store coordinator. When you encounter this step, Core Data does a few things prior to adding the store to the coordinator. First, Core Data analyzes the store’s model version. Next, it compares this version to the coordinator’s configured data model. If the store’s model version and the coordinator’s model version don’t match, Core Data will perform a migration, when enabled.

Types of migrations

In my own experience, I’ve found there are a few more migration variants than the simple distinction between lightweight and heavyweight migrations that Apple calls out. Below, I’ve provided the more subtle variants of migration names, but these names are not official categories by any means. You’ll start with the least complex form of migration and end with the most complex form.

Lightweight migrations

Lightweight migration is Apple’s term for the migration with the least amount of work involved on your part. This happens automatically when you use NSPersistentContainer, or you have to set some flags when building your own Core Data stack. There are some limitations on how much you can change the data model, but because of the small amount of work required to enable this option, it’s the ideal setting.

Manual migrations

Manual migrations involve a little more work on your part. You’ll need to specify how to map the old set of data onto the new set, but you get the benefit of a more explicit mapping model file to configure. Setting up a mapping model in Xcode is much like setting up a data model, with similar GUI tools and some automation.

Custom manual migrations

This is level 3 on the migration complexity index. You’ll still use a mapping model, but complement that with custom code to specify custom transformation logic on data. Custom entity transformation logic involves creating an NSEntityMigrationPolicy subclass and performing custom transformations there.

Fully manual migrations

Fully manual migrations are for those times when even specifying custom transformation logic isn’t enough to fully migrate data from one model version to another. Custom version detection logic and custom handling of the migration process are necessary. In this chapter, you’ll set up a fully manual migration to update data across non-sequential versions, such as jumping from version 1 to 4.

Getting started

Included with the resources for this book is a starter project called UnCloudNotes. Find the starter project and open it in Xcode.

A lightweight migration

In Xcode, select the UnCloudNotes data model file if you haven’t already. This will show you the Entity Modeler in the main work area. Next, open the Editor menu and select Add Model Version…. Name the new version UnCloudNotesDataModel v2 and ensure UnCloudNotesDataModel is selected in the Based on model field. Xcode will now create a copy of the data model.

@NSManaged var image: UIImage?

Inferred mapping models

It just so happens Core Data can infer a mapping model in many cases when you enable the shouldInferMappingModelAutomatically flag on the NSPersistentStoreDescription. Core Data can automatically look at the differences in two data models and create a mapping model between them.

Image attachments

Now the data is migrated, you need to update the UI to allow image attachments to new notes. Luckily, most of this work has been done for you.

func imagePickerController(_ picker: UIImagePickerController,
  didFinishPickingMediaWithInfo info:
  [UIImagePickerController.InfoKey: Any]) {

  guard let note = note else { return }

  note.image =
    info[UIImagePickerController.InfoKey.originalImage] as? UIImage

  _ = navigationController?.popViewController(animated: true)
}
override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)

  guard let image = note?.image else {
    titleField.becomeFirstResponder()
    return
  }

  attachedPhoto.image = image
  view.endEditing(true)
}
override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath)
                        -> UITableViewCell {

  let note = notes.object(at: indexPath)
  let cell: NoteTableViewCell
  if note.image == nil {
    cell = tableView.dequeueReusableCell(
      withIdentifier: "NoteCell",
      for: indexPath) as! NoteTableViewCell
  } else {
    cell = tableView.dequeueReusableCell(
      withIdentifier: "NoteCellWithImage",
      for: indexPath) as! NoteImageTableViewCell
  }

  cell.note = note
  return cell
}
noteImage.image = note.image

A manual migration

The next step in the evolution of this data model is to move from attaching a single image to a note to attaching multiple images. The note entity will stay, and you’ll need a new entity for an image. Since a note can have many images, there will be a to-many relationship.

import Foundation
import UIKit
import CoreData

class Attachment: NSManagedObject {
  @NSManaged var dateCreated: Date
  @NSManaged var image: UIImage?
  @NSManaged var note: Note?
}
@NSManaged var attachments: Set<Attachment>?
var image: UIImage? {
  return latestAttachment?.image
}

var latestAttachment: Attachment? {
  guard let attachments = attachments,
  	let startingAttachment = attachments.first else {
      return nil
  }

  return Array(attachments).reduce(startingAttachment) {
    $0.dateCreated.compare($1.dateCreated)
      == .orderedAscending ? $0 : $1
  }
}
import CoreData
func imagePickerController(_ picker: UIImagePickerController,
  didFinishPickingMediaWithInfo info:
  [UIImagePickerController.InfoKey: Any]) {

  guard let note = note,
    let context = note.managedObjectContext else {
      return
  }

  let attachment = Attachment(context: context)
  attachment.dateCreated = Date()
  attachment.image =
    info[UIImagePickerController.InfoKey.originalImage] as? UIImage
  attachment.note = note

  _ = navigationController?.popViewController(animated: true)
}

Mapping models

With lightweight migrations, Core Data can automatically create a mapping model to migrate data from one model version to another when the changes are simple. When the changes aren’t as simple, you can manually set up the steps to migrate from one model version to another with a mapping model.

Attribute mapping

There are two mappings, one named NoteToNote and another simply named Attachment. NoteToNote describes how to migrate the v2 Note entity to the v3 Note entity.

Relationship mapping

The migration is able to copy the images from Notes to Attachments, but as of yet, there’s no relationship linking the Note to the Attachment. The next step to get that behavior is to add a relationship mapping.

FUNCTION($manager,
  "destinationInstancesForEntityMappingNamed:sourceInstances:",
  "NoteToNote", $source)

One last thing

Before running this migration, you need to update the Core Data setup code to use this mapping model and not try to infer one on its own.

description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = false

A complex mapping model

The higher-ups have thought of a new feature for UnCloudNotes, so you know what that means. It’s time to migrate the data model once again! This time, they’ve decided that supporting only image attachments isn’t enough. They want future versions of the app to support videos, audio files or really add any kind of attachment that makes sense.

import UIKit
import CoreData

class ImageAttachment: Attachment {
  @NSManaged var image: UIImage?
  @NSManaged var width: Float
  @NSManaged var height: Float
  @NSManaged var caption: String
}

Mapping model

In the Xcode menu, choose File ▸ New File and select the iOS ▸ Core Data ▸ Mapping Model template. Select version 3 as the source model and version 4 as the target. Name the file UnCloudNotesMappingModel_v3_to_v4.

Custom migration policies

To move beyond FUNCTION expressions in the mapping model, you can subclass NSEntityMigrationPolicy directly. This lets you write Swift code to handle the migration, instance by instance, so you can call on any framework or library available to the rest of your app.

import CoreData
import UIKit

let errorDomain = "Migration"

class AttachmentToImageAttachmentMigrationPolicyV3toV4:
  NSEntityMigrationPolicy {

}
override func createDestinationInstances(
  forSource sInstance: NSManagedObject,
  in mapping: NSEntityMapping,
  manager: NSMigrationManager) throws {

  // 1
	let description = NSEntityDescription.entity(
    forEntityName: "ImageAttachment",
    in: manager.destinationContext)
	let newAttachment = ImageAttachment(
    entity: description!,
    insertInto: manager.destinationContext)

  // 2
  func traversePropertyMappings(block:
    (NSPropertyMapping, String) -> ()) throws {
    if let attributeMappings = mapping.attributeMappings {
      for propertyMapping in attributeMappings {
        if let destinationName = propertyMapping.name {
          block(propertyMapping, destinationName)
        } else {
          // 3
          let message =
            "Attribute destination not configured properly"
          let userInfo =
            [NSLocalizedFailureReasonErrorKey: message]
          throw NSError(domain: errorDomain,
                        code: 0, userInfo: userInfo)
        }
      }
    } else {
      let message = "No Attribute Mappings found!"
      let userInfo = [NSLocalizedFailureReasonErrorKey: message]
      throw NSError(domain: errorDomain,
                    code: 0, userInfo: userInfo)
    }
  }

  // 4
  try traversePropertyMappings {
    propertyMapping, destinationName in
    if let valueExpression = propertyMapping.valueExpression {
      let context: NSMutableDictionary = ["source": sInstance]
      guard let destinationValue =
        valueExpression.expressionValue(with: sInstance,
                                        context: context) else {
          return
      }

      newAttachment.setValue(destinationValue,
                             forKey: destinationName)
    }
  }

  // 5
  if let image = sInstance.value(forKey: "image") as? UIImage {
    newAttachment.setValue(image.size.width, forKey: "width")
    newAttachment.setValue(image.size.height, forKey: "height")
  }

  // 6
  let body =
    sInstance.value(forKeyPath: "note.body") as? NSString ?? ""
  newAttachment.setValue(body.substring(to: 80),
                         forKey: "caption")

  // 7
  manager.associate(sourceInstance: sInstance,
                    withDestinationInstance: newAttachment,
                    for: mapping)
}
let attachment = ImageAttachment(context: context)
attachment.caption = "New Photo"
var image: UIImage? {
  let imageAttachment = latestAttachment as? ImageAttachment
  return imageAttachment?.image
}

Migrating non-sequential versions

Thus far, you’ve walked through a series of data migrations in order. You’ve migrated the data from version 1 to 2 to 3 to 4, in sequence. Inevitably, in the real world of App Store launches, a user might skip an update and need to go from version 2 to 4, for example. What happens then?

A self-migrating stack

To begin implementing this solution, you’ll want to create a separate migration manager class. The responsibility of this class will be to provide a properly migrated Core Data stack, when asked. This class will have a stack property and will return an instance of CoreDataStack, as UnCloudNotes uses throughout, which has run through all the migrations necessary to be useful for the app.

import Foundation
import CoreData

class DataMigrationManager {
  let enableMigrations: Bool
  let modelName: String
  let storeName: String = "UnCloudNotesDataModel"
  var stack: CoreDataStack

  init(modelNamed: String, enableMigrations: Bool = false) {
    self.modelName = modelNamed
    self.enableMigrations = enableMigrations
  }
}
fileprivate lazy var stack: CoreDataStack =
  CoreDataStack(modelName: "UnCloudNotesDataModel")
fileprivate lazy var stack: CoreDataStack = {
  let manager = DataMigrationManager(
    modelNamed: "UnCloudNotesDataModel",
    enableMigrations: true)
  return manager.stack
}()
extension NSManagedObjectModel {

  private class func modelURLs(
    in modelFolder: String) -> [URL] {

    return Bundle.main
      .urls(forResourcesWithExtension: "mom",
      subdirectory: "\(modelFolder).momd") ?? []
  }

  class func modelVersionsFor(
    modelNamed modelName: String) -> [NSManagedObjectModel] {

    return modelURLs(in: modelName)
      .compactMap(NSManagedObjectModel.init)
  }

  class func uncloudNotesModel(
    named modelName: String) -> NSManagedObjectModel {

    let model = modelURLs(in: "UnCloudNotesDataModel")
      .filter { $0.lastPathComponent == "\(modelName).mom" }
      .first
      .flatMap(NSManagedObjectModel.init)
    return model ?? NSManagedObjectModel()
  }
}
class var version1: NSManagedObjectModel {
  return uncloudNotesModel(named: "UnCloudNotesDataModel")
}
var isVersion1: Bool {
  return self == type(of: self).version1
}
func == (firstModel: NSManagedObjectModel,
         otherModel: NSManagedObjectModel) -> Bool {
  return firstModel.entitiesByName == otherModel.entitiesByName
}
class var version2: NSManagedObjectModel {
  return uncloudNotesModel(named: "UnCloudNotesDataModel v2")
}

var isVersion2: Bool {
  return self == type(of: self).version2
}

class var version3: NSManagedObjectModel {
  return uncloudNotesModel(named: "UnCloudNotesDataModel v3")
}

var isVersion3: Bool {
  return self == type(of: self).version3
}

class var version4: NSManagedObjectModel {
  return uncloudNotesModel(named: "UnCloudNotesDataModel v4")
}

var isVersion4: Bool {
  return self == type(of: self).version4
}
private func store(at storeURL: URL,
	isCompatibleWithModel model: NSManagedObjectModel) -> Bool {

  let storeMetadata = metadataForStoreAtURL(storeURL: storeURL)

  return model.isConfiguration(
    withName: nil,
    compatibleWithStoreMetadata:storeMetadata)
}

private func metadataForStoreAtURL(storeURL: URL)
	-> [String: Any] {

  let metadata: [String: Any]
  do {
    metadata = try NSPersistentStoreCoordinator
    .metadataForPersistentStore(ofType: NSSQLiteStoreType,
                                  at: storeURL, options: nil)
  } catch {
    metadata = [:]
    print("Error retrieving metadata for store at URL:
      \(storeURL): \(error)")
  }
  return metadata
}
private var applicationSupportURL: URL {
  let path = NSSearchPathForDirectoriesInDomains(
		.applicationSupportDirectory,
		.userDomainMask, true)
		.first
  return URL(fileURLWithPath: path!)
}

private lazy var storeURL: URL = {
  let storeFileName = "\(self.storeName).sqlite"
  return URL(fileURLWithPath: storeFileName,
		         relativeTo: self.applicationSupportURL)
}()

private var storeModel: NSManagedObjectModel? {
  return
    NSManagedObjectModel.modelVersionsFor(modelNamed: modelName)
    .filter {
    	self.store(at: storeURL, isCompatibleWithModel: $0)
    }.first
}
class func model(named modelName: String,
	in bundle: Bundle = .main) -> NSManagedObjectModel {

  return
    bundle
    .url(forResource: modelName, withExtension: "momd")
    .flatMap(NSManagedObjectModel.init)
      ?? NSManagedObjectModel()
}
private lazy var currentModel: NSManagedObjectModel =
	.model(named: self.modelName)
func performMigration() {
}
var stack: CoreDataStack {
  guard enableMigrations,
       !store(at: storeURL,
         isCompatibleWithModel: currentModel)
  else { return CoreDataStack(modelName: modelName) }

  performMigration()
  return CoreDataStack(modelName: modelName)
}

The self-migrating stack

Now it’s time to start building out the migration logic. Add the following method to the DataMigrationManager class:

private func migrateStoreAt(URL storeURL: URL,
  fromModel from: NSManagedObjectModel,
  toModel to: NSManagedObjectModel,
  mappingModel: NSMappingModel? = nil) {

  // 1
  let migrationManager =
    NSMigrationManager(sourceModel: from, destinationModel: to)

  // 2
  var migrationMappingModel: NSMappingModel
  if let mappingModel = mappingModel {
    migrationMappingModel = mappingModel
  } else {
    migrationMappingModel = try! NSMappingModel
    .inferredMappingModel(
        forSourceModel: from, destinationModel: to)
  }

  // 3
  let targetURL = storeURL.deletingLastPathComponent()
  let destinationName = storeURL.lastPathComponent + "~1"
  let destinationURL = targetURL
    .appendingPathComponent(destinationName)

  print("From Model: \(from.entityVersionHashesByName)")
  print("To Model: \(to.entityVersionHashesByName)")
  print("Migrating store \(storeURL) to \(destinationURL)")
  print("Mapping model: \(String(describing: mappingModel))")

  // 4
  let success: Bool
  do {
    try migrationManager.migrateStore(from: storeURL,
			sourceType: NSSQLiteStoreType,
			options: nil,
			with: migrationMappingModel,
			toDestinationURL: destinationURL,
			destinationType: NSSQLiteStoreType,
			destinationOptions: nil)
    success = true
  } catch {
    success = false
    print("Migration failed: \(error)")
  }

  // 5
  if success {
    print("Migration Completed Successfully")

    let fileManager = FileManager.default
    do {
        try fileManager.removeItem(at: storeURL)
        try fileManager.moveItem(at: destinationURL,
                                 to: storeURL)
    } catch {
  	  print("Error migrating \(error)")
  	}
  }
}
if !currentModel.isVersion4 {
  fatalError("Can only handle migrations to version 4!")
}
if let storeModel = self.storeModel {
  if storeModel.isVersion1 {
    let destinationModel = NSManagedObjectModel.version2

    migrateStoreAt(URL: storeURL,
  		     fromModel: storeModel,
		       toModel: destinationModel)

    performMigration()
  } else if storeModel.isVersion2 {
    let destinationModel = NSManagedObjectModel.version3
    let mappingModel = NSMappingModel(from: nil,
							forSourceModel: storeModel,
						  destinationModel: destinationModel)

    migrateStoreAt(URL: storeURL,
			 fromModel: storeModel,
			   toModel: destinationModel,
		  mappingModel: mappingModel)

    performMigration()
  } else if storeModel.isVersion3 {
    let destinationModel = NSManagedObjectModel.version4
    let mappingModel = NSMappingModel(from: nil,
							forSourceModel: storeModel,
						  destinationModel: destinationModel)

    migrateStoreAt(URL: storeURL,
			 fromModel: storeModel,
			   toModel: destinationModel,
		  mappingModel: mappingModel)
  }
}

Testing sequential migrations

Testing this type of migration can be a little complicated, since you need to go back in time and run previous versions of the app to generate data to migrate. If you saved copies of the app project along the way, great!

Key points

  • A migration is necessary when you need to make changes to the data model.
  • Use the simplest migration method possible.
  • Lightweight migration is Apple’s term for the migration with the least amount of work involved on your part.
  • Heavyweight migrations, as described by Apple, can incorporate several different types of custom migration.
  • Custom migrations let you create a mapping model to direct Core Data to make more complex changes that lightweight can’t do automatically.
  • Once a mapping model has been created, do not change the target model.
  • Custom manual migrations go one step further from a mapping model and let you change the model from code.
  • Fully manual migrations let your app migrate sequentially from one version to the next preventing issues if a user skips updating their device to a version in between.
  • Migration testing is tricky because it is dependent on the data from the source store. Make sure to test several scenarios before releasing your app to the App Store.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now