I just ran into a similar problem in my app. After hours of looking around, I couldn't find what I was looking for, but I found a solution that works... Hopefully there is a better solution though.
Schema v1
public enum SchemaV1: VersionedSchema {
public static var versionIdentifier: Schema.Version = .init(1, 0, 0)
public static var models: [any PersistentModel.Type] {
[Reminder.self]
}
@Model
public final class Reminder: Hashable, Identifiable {
public var id: UUID
public var title: String
public var icon: ReminderIconStyle
// This is an enum, that I want to convert to a struct, so I need to migrate to the new model.
public var style: ReminderTimeStyle
...
}
}
Schema v2
public enum SchemaV2: VersionedSchema {
public static var versionIdentifier: Schema.Version = .init(2, 0, 0)
public static var models: [any PersistentModel.Type] {
[Reminder.self]
}
@Model
public final class Reminder: Hashable, Identifiable {
public var id: UUID
public var title: String
public var icon: ReminderIconStyle
// New to V2
public var recurrenceRule: RecurrenceRule?
// New to V2
@Attribute(.transformable(by: DateComponentsTransformer.self))
public var startDate: DateComponents
// New to V2
@Attribute(.transformable(by: DateComponentsTransformer.self))
public var dueDate: DateComponents
...
}
}
I haven't had to do any migrations in the past, since I have only added additional properties. Now I need to remove a property, and convert it to 3 new properties.
Remove
var style: ReminderTimeStyle
Add
var recurrenceRule: RecurrenceRule?
var startDate: DateComponents
var dueDate: DateComponents
In order for me to add the new properties, I need access to style: ReminderTimeStyle
so that I can get the relevant values to construct the new properties. The lightweight migration obviously failed, since there are major changes. So I started adding the logic for the custom migration.
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[.migrateV1toV2]
}
}
extension MigrationStage {
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let calendar = Calendar.current
let oldReminders = try? context.fetch(FetchDescriptor<SchemaV1.Reminder>())
for old in oldReminders ?? [] {
var startDate: DateComponents!
var dueDate: DateComponents!
var recurrenceRule: RecurrenceRule?
switch old.style {
case let .manual(start, duration):
startDate = calendar.dateComponents(in: .current, from: start)
dueDate = calendar.dateComponents(in: .current, from: start.advanced(by: duration))
case let .repeat(r):
startDate = calendar.dateComponents(in: .current, from: r.startDate)
dueDate = calendar.dateComponents(in: .current, from: r.endDate)
recurrenceRule = .init(frequency: r.frequency, interval: Int(r.every))
}
let new = SchemaV2.Reminder(
id: old.id,
title: old.title,
icon: old.icon,
startDate: startDate,
dueDate: dueDate,
recurrenceRule: recurrenceRule,
...
)
context.insert(new)
context.delete(old)
}
try? context.save()
}, didMigrate: { context in
print("Migrated!", context.container)
}
)
}
Every time I would run the migration using that code, it would always fail when creating SchemaV2.Reminder
with some weird error messages like:
Fatal error: Expected only Arrays for Relationships - RecurrenceRule
Fatal error: Unexpected type for CompositeAttribute: Optional<RecurrenceRule>
So I thought the problem was with the new property. It turns out that it was failing on all of the new properties, and if I changed the order it would crash on whichever one was first.
I found that you only have access to the "old" models in willMigrate
, and you only have access to the "new" models in didMigrate
. And you can't just move the migration code into the didMigrate
block, because the breaking schema changes have to be fixed before didMigrate
will run. So there is no way to do the type of migration that I was attempting. That means I have to leave the old database table around for this migration, so I have access to both models. I assume I can remove the table in the next schema version.
- Move the
willMigrate
code into the didMigrate
block so that we have access to the new Reminder
model. - Rename the
SchemaV2.Reminder
model to something else i.e. SchemaV2.Reminder2
- Make sure both
SchemaV1.Reminder
and SchemaV2.Reminder2
are included in the SchemaV2.models
array.
public typealias CurrentSchema = SchemaV2
public typealias Reminder = CurrentSchema.Reminder2
public enum SchemaV2: VersionedSchema {
public static var versionIdentifier: Schema.Version = .init(2, 0, 0)
public static var models: [any PersistentModel.Type] {
[SchemaV1.Reminder.self, Reminder2.self]
}
@Model
public final class Reminder2: Hashable, Identifiable {
...
}
}
extension MigrationStage {
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
...
}
)
}
Reminder
can't be used as a model name, since that refers to the original table in the database. The typealias
helps make that not as bad.- The original
Reminder
table still exists in the database, it just won't be used. You can remove it, but it will take an additional migration stage. - The migration code is not as straightforward as it should be.
willMigrate
is where logic goes if you can migrate your data without creating new models and the migration is pretty simple. Breaking model changes have to be fixed here.didMigrate
is where logic goes if you need access to the new model, but you can't access old models from here.- Since I needed access to the old model and the new one in order to do the migration I had to keep the old model around.
I hope this helps someone!