SwiftDataでマイグレーション時に発生するエラーへの対応方法(Cannot migrate store in-place: Validation error missing attribute values on mandatory destination attribute)
SwiftDataを使ってスキーマのマイグレーションをする際に,モデルへのプロパティの追加がうまくマイグレートできず,解決方法を探りました.
TL;DR
MigrationPlan
のwillMigrate
で新しいスキーマに変換したものを保管したうえで古いスキーマを削除し,didMigrate
で新しいスキーマを挿入する
環境
- Xcode: 16.2
- Swift: 5
- iOS: 18.2
問題
Cat
というモデルにoptionalではないプロパティを1つ追加してスキーマをマイグレーションするケースで問題が発生しました.
まずはバージョン1のCat
スキーマを定義してデータを保存します.
import SwiftData
typealias Cat = SchemaV1.Cat
struct SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [Self.Cat.self]
}
extension SchemaV1 {
@Model
class Cat {
var name: String
init(name: String) {
self.name = name
}
}
}
let container = try! ModelContainer(for: Cat.self)
let context = ModelContext(container)
context.insert(Cat(name: "Rei"))
try! context.save()
その後,Catスキーマにage
プロパティを追加したものをバージョン2として定義します.
// V1からV2に変更
typealias Cat = SchemaV2.Cat
// 追加
struct SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [Self.Cat.self]
}
// 追加
extension SchemaV2 {
@Model
class Cat {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
}
let container = try! ModelContainer(for: Cat.self)
let context = ModelContext(container)
こうすると,ModelContext
の作成で以下のようなエラーが発生しクラッシュしました.(一部抜粋)
error: addPersistentStoreWithType:configuration:URL:options:error: returned error NSCocoaErrorDomain (134110)
error: reason : Cannot migrate store in-place: Validation error missing attribute values on mandatory destination attribute
error: NSUnderlyingError : Error Domain=NSCocoaErrorDomain Code=134110 “An error occurred during persistent store migration.” UserInfo={entity=Cat, attribute=age, reason=Validation error missing attribute values on mandatory destination attribute}
V2のスキーマはoptionalではないプロパティが追加されたので,自動マイグレーションが成功しないようです.
(var age: Int?
とすればエラーは発生しませんでした.)
解決方法の1つとしてoptionalにしてしまう or デフォルト値を入れてしまうという方法があるようですが,マイグレーションのためにモデルの制約を緩めるのは嫌だったので別の方法を考えました.
以下の試みはMigratonPlan
を次のように定義していることを前提としています.
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[MigrationV1toV2.execute]
}
}
試み1: willMigrateで古いスキーマを削除して新しいスキーマを挿入する(NG)
fileprivate class MigrationV1toV2 {
static let execute = MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self, willMigrate: { context in
let old = try! context.fetch(FetchDescriptor<SchemaV1.Cat>())
let new = old.map { SchemaV2.Cat(name: $0.name, age: 0) }
try! context.delete(model: SchemaV1.Cat.self)
new.forEach { context.insert($0) }
try! context.save()
}, didMigrate: nil)
}
try! context.save()
で以下のエラーが発生してクラッシュしました.
Thread 1: Fatal error: What kind of backing data is this? SwiftData._KKMDBackingData<SwiftDataMigrationProblem.SchemaV1.Cat>
マイグレーション前のModelContext
はV2のスキーマではないからでしょうか.
(lldb) po context.container.schema
Schema
schemaEncodingVersion: 1.0.0
encodingVersion: 1.0.0
entities:
Entity - name: Cat
superentity:
subentities:
storedProperties:
Attribute - name: name, options: [], valueType: String, defaultValue: nil, hashModifier: nil
inheritedProperties:
uniquenessConstraints:
indices:
(SchemaEncodingVersion
とencodingVersion
はスキーマのバージョンではないことに注意してください.Attribute
がname
だけなのを見てV1のスキーマであると判断しています.)
試み2: didMigrateで古いスキーマを削除して新しいスキーマを挿入する(NG)
試み1と同じことをdidMigrate
で行なってみます.
fileprivate class MigrationV1toV2 {
static let execute = MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self, willMigrate: nil, didMigrate: { context in
let old = try! context.fetch(FetchDescriptor<SchemaV1.Cat>())
let new = old.map { SchemaV2.Cat(name: $0.name, age: 0) }
try! context.delete(model: SchemaV1.Cat.self)
new.forEach { context.insert($0) }
try! context.save()
})
}
「問題」と同じエラーが出てクラッシュしました.
マイグレーションが実行される時点(willMigrate
が実行され終わる前まで)にV1のスキーマが残っているとダメなようです.
試み3: willMigrateで古いスキーマを保管したうえで削除し,didMigrateで新しいスキーマに変換して挿入する(NG)
fileprivate class MigrationV1toV2 {
static var old = [SchemaV1.Cat]()
static let execute = MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self, willMigrate: { context in
old = try! context.fetch(FetchDescriptor<SchemaV1.Cat>())
try! context.delete(model: SchemaV1.Cat.self)
try! context.save()
}, didMigrate: { context in
let new = old.map { SchemaV2.Cat(name: $0.name, age: 0) }
new.forEach { context.insert($0) }
try! context.save()
})
}
SchemaV2.Cat(name: $0.name, age: 0)
で$0.name
を参照する際にエラーが発生しクラッシュしました.
Thread 1: Fatal error: This model instance was destroyed by calling ModelContext.reset and is no longer usable. PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://253C9372-383C-4284-844A-D29EA57D51E3/Cat/p1), implementation: SwiftData.PersistentIdentifierImplementation)
willMigrate
を抜けた時点でModelContext
が無効になった(?)ので参照できなくなったということでしょうか.(たぶん)
試み4: willMigrateで新しいスキーマに変換したものを保管したうえで古いスキーマを削除し,didMigrateで新しいスキーマを挿入する(OK)
fileprivate class MigrationV1toV2 {
static var new = [SchemaV2.Cat]()
static let execute = MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self, willMigrate: { context in
let old = try! context.fetch(FetchDescriptor<SchemaV1.Cat>())
new = old.map { SchemaV2.Cat(name: $0.name, age: 0) }
try! context.delete(model: SchemaV1.Cat.self)
try! context.save()
}, didMigrate: { context in
new.forEach { context.insert($0) }
try! context.save()
})
}
didMigrate
ではModelContext
がV2のスキーマなので問題がおきないようです.(たぶん)
(lldb) po context.container.schema
Schema
schemaEncodingVersion: 1.0.0
encodingVersion: 1.0.0
entities:
Entity - name: Cat
superentity:
subentities:
storedProperties:
Attribute - name: name, options: [], valueType: String, defaultValue: nil, hashModifier: nil
Attribute - name: age, options: [], valueType: Int, defaultValue: nil, hashModifier: nil
inheritedProperties:
uniquenessConstraints:
indices:
追記
もう少し複雑なモデルにおいて「試み4」の方法でマイグレーションを行った際,以下のようなエラーが出ました.
SwiftData/BackingData.swift:432: Fatal error: Expected only Arrays for Relationships - SomeStruct
SomeStruct
は自分で定義したstructで,新しいスキーマバージョンで追加した際にMigrationPlan
で発生しました.
(ただし,このモデルでは過去も含めてリレーションは使っておらず,SomeStruct
も単純なInt
のラッパなので,なぜこんなエラーが出るのか不明です.)
あまり@Model
のクラスをwillMigrate
とdidMigrate
を跨いで保持しないほうがトラブルが少ないかもしれないと思い,タプルで保管する方法も書いておきます.
fileprivate class MigrationV1toV2 {
static var old = [(String)]()
static let execute = MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self, willMigrate: { context in
old = try! context.fetch(FetchDescriptor<SchemaV1.Cat>()).map { ($0.name) }
try! context.delete(model: SchemaV1.Cat.self)
try! context.save()
}, didMigrate: { context in
old.map { SchemaV2.Cat(name: $0, age: 0) }.forEach { context.insert($0) }
try! context.save()
})
}