bauerMusic
bauerMusic

Reputation: 6166

SwiftData migration crashing with default value

Doing light migration, using default value, rather than optional value for new param does not seems to work and keeps crashing.
I recall an Apple SwiftData migration guide, but it seems to be gone, so I could not find any mention regarding this.

Initial version

@Model
final class Car {
    var timestamp: Date
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

Updated version (using default value)

@Model
final class Car {
    var timestamp: Date
    var engine: Engine = Engine()

    init(timestamp: Date, engine: Engine) {
        self.timestamp = timestamp
        self.engine = engine
    }
}

@Model
class Engine {
    var horsepower: Int = 0
    init(horsepower: Int) {
        self.horsepower = horsepower
    }

    init() {
        self.horsepower = 0
    }
}

This crashes: entity=Car, attribute=engine, reason=Validation error missing attribute values on mandatory destination relationship

Using optional

@Model
final class Car {
    var timestamp: Date
    var engine: Engine? // <<< Using optional for new property

    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

This one works.

Here's the migration plan

enum DASchemeV1: VersionedSchema {
    
    static var versionIdentifier: Schema.Version {
        Schema.Version(1, 0, 0)
    }
    
    static var models: [any PersistentModel.Type] {
        [Car.self]
    }
    
    @Model
    final class Car {
        var timestamp: Date
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
}

enum DASchemeV2: VersionedSchema {
    
    static var versionIdentifier: Schema.Version {
        Schema.Version(1, 0, 1)
    }
    
    static var models: [any PersistentModel.Type] {
        [
            Car.self,
            Engine.self
        ]
    }
    
    @Model
    final class Car {
        var timestamp: Date
        var engine: Engine = Engine()

        init(timestamp: Date, engine: Engine) {
            self.timestamp = timestamp
            self.engine = engine
        }
    }

    @Model
    class Engine {
        var horsepower: Int = 0
        init(horsepower: Int) {
            self.horsepower = horsepower
        }

        init() {
            self.horsepower = 0
        }
    }
}

struct ItemMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [DASchemeV1.self, DASchemeV2.self]
    }
    
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: DASchemeV1.self,
        toVersion: DASchemeV2.self
    )
}

Does this means that only optional properties can be added in migration? Or that using a default value needs to do a custom migration?


Edit:

Some information about custom migration as I understand it:

static let migrationV2ToV2 = MigrationStage.custom(
    fromVersion: SchemeV1.self,
    toVersion: SchemeV2.self,
    willMigrate: { context in
        // Context here can read old store: SchemeV1
        let items = try context.fetch(FetchDescriptor<SchemeV1.Item>())
    }, didMigrate: { context in
        // Context here can read the new store: SchemeV2
        let items = try context.fetch(FetchDescriptor<SchemeV2.Item>())
    })


Is there a simple example for a migration (light or custom) for adding a non primitive non optional property?

From

@Model
final class User {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

To

@Model
final class Company {
    var name: String
    init(name: String) {
        self.name = name
    }
}

@Model
final class User {
    var name: String
    var company: Company = Company(name: "Example")
    
    init(name: String, company: Company) {
        self.name = name
        self.company = company
    }
}

Upvotes: 0

Views: 68

Answers (1)

otaviokz
otaviokz

Reputation: 382

You can add non optional attributes to a SwiftData model in a migration, but to have a default value you must use a custom migration:

 static let migrateV2toV3: MigrationStage = .custom(
        fromVersion: DataSchemaV1.self,
        toVersion: DataSchemaV2.self,
        willMigrate: nil
    ) { context in // didMigrate
        let cars = try context.fetch(FetchDescriptor<DataSchemaV2.Car>())
        cars.forEach { $0.engine = Engine() }
        try context.save()
    }
}

Unfortunately, adding properties with default values require a custom migration where you set that value. It's not smart enough to both add the new field and populate it with a custom value in light migrations.

Upvotes: 1

Related Questions