Search code examples
swiftuicore-data-migrationswiftdata

SwiftData migration ignores custom SchemaMigrationPlan


I’m trying to migrate my Core Data app to use SwiftData. The goal is to completely replace Core Data.

While working on the new version, I’m also changing the database structure quite substantial (add entities, add attributes, rename attributes, add relationships, convert optional attributes to non-optionals, …). Therefore I have to use a custom migration. Following the WWDC talk I’ve added a SchemaMigrationPlan and as well as my VersionedSchema’s

For some reason, it doesn’t even arrive at my custom migration. What am I doing wrong?

The console doesn’t even print the print() statement in the custom migration.

App

@main
struct MyApp: App {
    let container: ModelContainer
    let dataUrl = URL.applicationSupportDirectory.appending(path: "MyApp.sqlite")
    
    init() {
        let finalSchema = Schema([Loan.self, Item.self, Person.self, Location.self])
        
        do {
            container = try ModelContainer(
                for: finalSchema,
                migrationPlan: MyAppMigrationPlan.self,
                configurations: ModelConfiguration(url: dataUrl))
        } catch {
            fatalError("Failed to initialize model container.")
        }
    }

    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

PS: I need to point to a custom url for my Core Data file, because I renamed my app a few years ago and didn’t change the name of the NSPersistentContainer back then.

Migration Plan

enum MyAppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2Step1.self, SchemaV2Step2.self]
    }
    
    
    // Migration Stages
    static var stages: [MigrationStage] {
        [migrateV1toV2Step1, migrateV1toV2Step2]
    }
    
    
    // v1 to v2 Migrations
    static let migrateV1toV2Step1 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2Step1.self,
        willMigrate: nil,
        didMigrate: { context in
            print("Start migrating Core Data to SwiftData") // This isn’t printed in the Xcode debug console!?

            let loans = try? context.fetch(FetchDescriptor<SchemaV2Step1.Loan>())
            
            loans?.forEach { loan in
                // Mapping old optional attribute to new non-optional attribute
                loan.amount = Decimal(loan.price ?? 0.0)
                
                // doing other stuff
            }
            
            // Save all changes
            try? context.save()
        })

    
    // Adding new attributes and deleting old attributes replaced in step 1.
    static let migrateV1toV2Step2 = MigrationStage.lightweight(
        fromVersion: SchemaV2Step1.self,
        toVersion: SchemaV2Step2.self)
}

ModelSchema Excerpts

This schema mirrors my old Core Data stack and was created with Xcodes „Create SwiftData Code“ action. For some reason the Xcode generated code changes the price attribute to be a Double. The Core Data definition uses Decimal tough.

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [Loan.self, Person.self]
    }

    // definition of all the model classes
    @Model
    final class Loan {
        var price: Double? = 0.0
        // …
    }

    final class Person {
        // …
    }
}

Besides other things, this schema introduces a new non-optional attribute amount that will replace price:

enum SchemaV2Step1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 5, 0)
    
    static var models: [any PersistentModel.Type] {
        [Loan.self, Person.self, Location.self]
    }

    // definition of all the model classes
    @Model
    final class Loan {
        var amount: Decimal = 0.0 // Will replace `price`
        var price: Double? = 0.0
        // …
    }

    final class Person {
        // …
    }

    // …
}

This final schema mostly deletes the old replaced attributes like mentioned above:

enum SchemaV2Step2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [Loan.self, Item.self, Person.self, Location.self]
    }

    // definition of all the model classes
    @Model
    final class Loan {
        var amount: Decimal = 0.0
        // …
    }

    final class Person {
        // …
    }

    // …
}

To keep things shorter, I’ll not show all of the details for all of the schemas. The issue appears to be somewhere else anyway …

Edit

Initially it crashed during the migration with:

UserInfo={entity=Loan, attribute=amount, reason=Validation error missing attribute values on mandatory destination attribute}

I’ve fixed that by setting a default value for all non-optionals. Thought the problem is that my custom migration code still isn’t executed and thus it simply uses all the default values instead of my custom mapping. Any ideas why?


Solution

  • I've found it to solve this case. Seems like it won't read your migration plan while passing the Schema parameter for using custom configuration. (I could not figure out why it's happening)

    Not using custom configuration gets ModelContainer to work well for migration.

    Instead, we can use this initializer for ModelContainer.

    public convenience init(for forTypes: PersistentModel.Type..., migrationPlan: (SchemaMigrationPlan.Type)? = nil, configurations: ModelConfiguration...) throws
    

    example code

    let databasePath = URL.documentsDirectory.appending(path: "database.store")
    
    let configuration = ModelConfiguration(url: databasePath)
    
    let container = try ModelContainer.init(
       for: Item.self, Foo.self, Bar.self,
       migrationPlan: Plan.self,
       configurations: configuration
    )
    

    To check if your migration has loaded correctly,

    print(modelContainer.migrationPlan)
    

    must print NOT nil.