ottijp blog

SwiftDataでマイグレーション時に発生するエラーへの対応方法(Cannot migrate store in-place: Validation error missing attribute values on mandatory destination attribute)

  • 2025-01-18

SwiftDataを使ってスキーマのマイグレーションをする際に,モデルへのプロパティの追加がうまくマイグレートできず,解決方法を探りました.

TL;DR

MigrationPlanwillMigrateで新しいスキーマに変換したものを保管したうえで古いスキーマを削除し,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:

SchemaEncodingVersionencodingVersionはスキーマのバージョンではないことに注意してください.Attributenameだけなのを見て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のクラスをwillMigratedidMigrateを跨いで保持しないほうがトラブルが少ないかもしれないと思い,タプルで保管する方法も書いておきます.

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()
  })
}

ottijp
都内でアプリケーションエンジニアをしています
© 2025, ottijp