0

I use SwiftData in my app and I want to update a property in my model.

This property (lets call it myData) is a Codable struct.

I have added to new properties in myData, without changing anything in my model, it crashes at app start.

If I erase the app and start from scratch, it works perfectly with my updated model.

I think there is a problem with schema migration but I don't know how to handle it.

Some more info, my model:

@Model
class CloudInterval: Encodable, Comparable {
    var id = UUID()
    var name: String = ""
    var order: Int = 0
    // targets
    var targetData = TargetData()
    @Relationship(deleteRule: .nullify, inverse: \CloudBlock.intervals)
    var block: CloudBlock?

    init() {
    }
}

My problem is with targetData

Initially, it’s :

struct TargetData: Codable, Equatable {
    var minHeartRate: Int = 0
    var maxHeartRate: Int = 0
    var speed: Double = 0
    var time: TimeInterval = 0
    var distance: Double = 0
    var enableTargetSpeed = false
    var enableTargetHeartRate = false
    var enableTargetTime = false
    var speedTolerance: Double = 0.02
}

And I just want to add 2 new properties (1 Bool, 1 Int), and when I do that, the app crashes. I don’t how to migrate to the new version.

4
  • Indeed, you need a migration. Else, when trying to match your model with the saved data, it fails, hence your crash. It's hard to tell what you should do exactly in your case, we'd need to have initial model, and afterwards to help you. Commented Nov 11, 2023 at 18:45
  • Unrelated but any reason why you haven't made TargetData a model or even better included all the properties directly in CloudInterval? Commented Nov 12, 2023 at 10:37
  • It’s because it’s used in other circumstances outside this model Commented Nov 12, 2023 at 12:25
  • Well maybe reusing it in your model perhaps cause more drawbacks than benefits. I did have a go at creating a migration for this but was unable to solve this because I got a Core Data error that I couldn't get past. If this was because of a mistake I made or if this kind of migration isn't supported yet is unclear. Commented Nov 12, 2023 at 12:31

3 Answers 3

2

I came across this issue myself. I used a Codable struct in one of my models.

If you are still in development the easiest solution would be to delete the SqlLite database and start from scratch. But if your app is already out there and the user have data stored you need to migrate it.

SwiftData stores your Codable struct as encoded value. So every attempt to decode it back will fail as soon as you change any property of the struct.

You will need to use 2 Migrations to change the initial struct. We can only make changes to the entities before and after the Migration is done. SwiftData applies the changes to the underlying SQLite storage by itself. We cannot control how this is done.

In the first Migration we will create another property and map the data of TargetData to it. In the second migration we drop the old property. From now on you need to use the new property. We cannot name it the same as the old name, Migration will fail if we do so.

// to access the Items in your other code more simple use a type alias
// this will point to the newest version of CloudInterval and TargetData
typealias CloudInterval = DataSchemaV3.CloudInterval
typealias TargetData = DataSchemaV3.TargetData

enum DataSchemaV1: VersionedSchema{
    
    static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Self.CloudInterval.self] }
    
    @Model
    class CloudInterval {
        // ... other properties
        // specify the type explicitly to avoid confusion
        var targetData: DataSchemaV1.TargetData = TargetData()
        init(){}
    }

    struct TargetData: Codable, Equatable {
        var minHeartRate: Int = 0
        var maxHeartRate: Int = 0
        var speed: Double = 0
        var time: TimeInterval = 0
        var distance: Double = 0
        var enableTargetSpeed = false
        var enableTargetHeartRate = false
        var enableTargetTime = false
        var speedTolerance: Double = 0.02
    }
}

enum DataSchemaV2: VersionedSchema{
    
    static var versionIdentifier: Schema.Version = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Self.CloudInterval.self] }
    
    @Model
    class CloudInterval {
        // ... other properties
        // specify the type explicitly to avoid confusion
        var targetData: DataSchemaV1.TargetData = DataSchemaV1.TargetData()
        // we create a property with a different name that will hold our new values
        var newTargetData: DataSchemaV3.TargetData = DataSchemaV3.TargetData()
        init(){}
    }
}


enum DataSchemaV3: VersionedSchema{
    
    static var versionIdentifier: Schema.Version = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] { [Self.CloudInterval.self] }
    
    @Model
    class CloudInterval {
        // ... other properties
        // we now have only the new created property here
        var newTargetData: DataSchemaV3.TargetData = DataSchemaV3.TargetData()
        init(){}
    }

    struct TargetData: Codable, Equatable {
        var minHeartRate: Int = 0
        var maxHeartRate: Int = 0
        var speed: Double = 0
        var time: TimeInterval = 0
        var distance: Double = 0
        var enableTargetSpeed = false
        var enableTargetHeartRate = false
        var enableTargetTime = false
        var speedTolerance: Double = 0.02
        // added properties here
        var integerValue: Int = 0
        var booleanValue: Bool = false
    }
}

enum DataMigrationPlan: SchemaMigrationPlan{
    // here we add the types involved in the migration
    static var schemas: [any VersionedSchema.Type] { [DataSchemaV1.self, DataSchemaV2.self, DataSchemaV3.self]}
    
    // return the stages that should be run during migration
    static var stages: [MigrationStage] { [migrationV1ToV2, migrationV2ToV3] }
    
    static let migrationV1ToV2 = MigrationStage.custom(fromVersion: DataSchemaV1.self, toVersion: DataSchemaV2.self, willMigrate: nil) { context in
        // this will run after the migration completed and we now have V2 items
        let v2CloudIntervals = try context.fetch(FetchDescriptor<DataSchemaV2.CloudInterval>())
        
        v2CloudIntervals.forEach{ v2CloidInterval in // initialize the new TargetData here and assign the values
                                                     // from the old TargetData
            v2CloidInterval.newTargetData = DataSchemaV3.TargetData()
        }
        // save changes to the context
        try context.save()
    }
    // with this migration we drop the old targetData property
    static let migrationV2ToV3 = MigrationStage.lightweight(fromVersion: DataSchemaV2.self, toVersion: DataSchemaV3.self)
}

The only thing left to do would be to use the migration while creating the container

do {
    let container = try ModelContainer(for: schema, migrationPlan: DataMigrationPlan.self, configurations: [modelConfiguration])        
    return container
} catch {
    fatalError("Could not create ModelContainer: \(error)")
}
Sign up to request clarification or add additional context in comments.

2 Comments

Thanks a lot.It looks promising ! I have found a workaround to avoid this complex data migration, but I will probably need it in a near future !
@RB: What was your workaround ?
1

You should make migrations when you want to make changes to your data model. If your app is not published on the App Store, I recommend deleting your application and reinstalling it. However, if you want to update your app that is on the App Store, I prefer watching this video. SwiftData Migrations Tutorial

Comments

-1

There is 2 type of migrations in SwiftData: lightweight and complex.

While lightweight migration will execute automatically, for bigger changes you need to write your own migration plan.

Some lightweight changess:

  • Adding one or more new model.
  • Adding one or more new properties that have a default value.
  • Renaming one or more properties.
  • Deleting properties from a model.
  • Adding or removing the .externalStorage or .allowsCloudEncryption attributes.
  • Adding the .unique attribute and all values for that property are already unique.
  • Adjusting the delete rule on relationships.

The problem in you case is you want to update a complex type, not only add two more properties.

You have more options to solve this:

1. You can make the TargetData @Model as well, and create one-to-one relationships between them.

This mean that every CloudInterval object has exactly one TargetData object attached to it. In this case, be careful not to try to insert both CloudInterval and TargetData objects, because inserting one automatically inserts the other.

2. You can create complex migration using VersionedSchema

Doing this requires four steps:

  • You need to define multiple versions of our data model.
  • You wrap each of those versions inside an enum that conforms to the VersionedSchema protocol. (It’s an enum only because we won’t actually be instantiating these directly.)
  • You create another enum that conforms to the SchemaMigrationPlan protocol, which is where you'll handle the migrations between each model version.
  • You then create a custom ModelContainer configuration that knows to use the migration plan as needed.

You can learn more about SchemaMigrationPlan from the following links:

1 Comment

You are not addressing the real issue, OP has changed a struct that is used in a model class by adding properties rather than adding properties to a model class. I tried doing a migration with this scenario but it didn’t work.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.