Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 831
alexkaessner Avatar asked Oct 23 '25 21:10

alexkaessner


2 Answers

After a lot of testing I have figured out a good way to perform a custom migration of a @Model object. Important rules:

  1. All your @Model objects need to be declared in the schemes .models array even if you do not use them during migration.
  2. In willMigrate step you only have access to the old schemes models. If you try to create a model from the new scheme you will get a crash with a funky error message that does not help.
  3. In didMigrate you only have access to the new models and the old ones need to be removed from your database.

In order to make this work in an easy way I created a MigrationModel struct that DOES NOT USE SwiftData. I use that struct and a static var to hold my data between the willMigrate and didMigrate step

private static var migratingConversations = [ConversationConversionModel]()

static let migrateFromV1toV2 = MigrationStage.custom(
    fromVersion: DataSchemaV1.self,
    toVersion: DataSchemaV2.self,
    willMigrate: { context in
        // Fetch current models
        let conversations = try context.fetch(FetchDescriptor<DataSchemaV1.Conversation>())
        // Convert the current models to a simple struct and save in-memory
        migratingConversations = conversations.map { ConversationConversionModel(conversation: $0) }
        // Delete the models from the Database
        try context.delete(model: DataSchemaV1.Conversation.self)
        try context.save()

    }, didMigrate:  { context in
        // Iterate the simple struct objects currently in memory
        migratingConversations.forEach { migrationModel in
            // Create the new V2 data model from the in-memory struct
            // Store it in the database (.buildModel() is just a helper method)
            context.insert(migrationModel.buildModel())
        }
        try context.save()
    }
)
like image 158
LemonandlIme Avatar answered Oct 25 '25 16:10

LemonandlIme


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.

like image 40
Muukii Avatar answered Oct 25 '25 15:10

Muukii



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!