mcpField
A
axiom-swiftdata-migration

axiom-swiftdata-migration

github.com/charleswiltgenデータ・データベース1.2k187 回インストール今日更新
リポジトリ

axiom-swiftdata-migration について

SwiftData のスキーマ移行を扱う開発者向けスキルです。軽量移行とカスタム移行の選択、リレーションの維持、アップグレードの検証を、移行段階の制約と実践的なテスト方法を交えて説明します。

主な機能

  1. 01軽量移行とカスタム移行の判断
  2. 02プロパティ型変更の二段階移行
  3. 03リレーション移行パターン
  4. 04VersionedSchema の設定
  5. 05移行テストのチェックリスト

活用シーン

  1. 01永続化プロパティの型変更
  2. 02モデル間リレーションの再構成
  3. 03重複データがある項目への一意制約追加
  4. 04実機でのアップグレードテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftdata-migration

インストール後、Claude Code で/axiom-swiftdata-migrationを入力して読み込まれたか確認します。

詳細

SwiftData のカスタムスキーマ移行

概要

SwiftData のスキーマ移行では、モデルの変更時にデータを安全に移行します。基本原則 SwiftData の willMigrate からアクセスできるのは OLD モデルのみで、didMigrate からアクセスできるのは NEW モデルのみです。両方に同時にアクセスすることはできません。この制約が、すべての移行戦略の形を決めます。

必要要件 iOS 17 以降、Swift 5.9 以降
対象 iOS 26 以降(propertiesToFetch などの機能)

カスタム移行が必要な場合

軽量移行(自動)

SwiftData は、次の変更を自動的に移行できます。

  • ✅ 新しいオプショナルプロパティの追加
  • ✅ デフォルト値を持つ新しい必須プロパティの追加
  • ✅ プロパティの削除
  • ✅ プロパティの名前変更(@Attribute(originalName:) を使用)
  • ✅ リレーションシップの削除ルールの変更
  • ✅ 新しいモデルの追加
カスタム移行(このスキル)

次の場合はカスタム移行が必要です。

  • ❌ プロパティ型の変更(String → AttributedString、Int → String)
  • ❌ オプショナルプロパティを必須にする(既存の null を補完する必要があります)
  • ❌ 複雑なリレーションシップの再構成
  • ❌ データ変換(フィールドの分割/統合)
  • ❌ 一意性制約の追加時の重複排除

プロンプトの例

以下は、このスキルでの回答を想定した、開発者から実際に寄せられる質問です。

1. 「プロパティを String から AttributedString に変更する必要があります。リレーションシップを維持したまま既存データを移行するにはどうすればよいですか?」

→ このスキルでは、willMigrate/didMigrate の制約を回避できる 2 段階の移行パターンを紹介します。

2. 「カスケード削除を伴う 1 対多のリレーションシップをモデルで使っています。型変更の移行時にこれを維持するにはどうすればよいですか?」

→ このスキルでは、リレーションシップの事前取得と、スキーマバージョン間で逆方向リレーションシップを維持する方法を説明します。

3. 「Tags と Notes の間に多対多のリレーションシップがあります。移行が 'Expected only Arrays for Relationships' というエラーで失敗します。何が問題ですか?」

→ このスキルでは、逆方向リレーションシップを明示的に指定する必要性と、iOS 17.0 のアルファベット順命名に関するバグについて説明します。

4. 「すべてのリレーションシップを維持したまま、モデルの名前を変更する必要があります。」

→ このスキルでは、軽量移行における @Attribute(originalName:) のパターンを紹介します。

5. 「シミュレーターでは移行が機能しますが、既存データがある実機ではクラッシュします。」

→ このスキルでは、実機テストの重要性を強調し、シミュレーターでの成功が本番環境での安全性を保証しない理由を説明します。

6. 「変更していないモデルも含め、すべてのモデルを各 VersionedSchema にコピーしなければならないのはなぜですか?」

→ このスキルでは、SwiftData の設計について説明します。各 VersionedSchema は差分ではなく、完全なスナップショットです。

7. 「'The model used to open the store is incompatible with the one used to create the store' というエラーが表示されます。」

→ このスキルでは、スキーマバージョンの不一致をデバッグする手順を紹介します。

8. 「本番環境にリリースする前に、SwiftData の移行をテストするにはどうすればよいですか?」

→ このスキルでは、移行テストのワークフロー、実機テストの要件、検証戦略について説明します。


willMigrate/didMigrate の制約

重要 これは、すべての SwiftData 移行パターンの形を決めるアーキテクチャ上の制約です。

アクセスできるもの
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ CAN access: SchemaV1 models (old)
        let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())

        // ❌ CANNOT access: SchemaV2 models
        // SchemaV2.Note doesn't exist yet
    },
    didMigrate: { context in
        // ✅ CAN access: SchemaV2 models (new)
        let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())

        // ❌ CANNOT access: SchemaV1 models
        // SchemaV1.Note is gone
    }
)
これが重要な理由

1 つの移行ステージで、古い型から新しい型へデータを直接変換することはできません。例:

// ❌ IMPOSSIBLE - you can't do this in one stage
willMigrate: { context in
    let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    for oldNote in oldNotes {
        let newNote = SchemaV2.Note()  // ❌ Doesn't exist yet!
        newNote.content = oldNote.contentAsAttributedString()
    }
}

解決策 2 段階の移行パターンを使用します(以下で説明します)。


基本パターン

パターン 1:基本的な VersionedSchema のセットアップ

スキーマの各バージョンを、それぞれ VersionedSchema として定義する必要があります。

import SwiftData

enum NotesSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // ALL models, even if unchanged
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String
        var content: String  // Original type
        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model
    final class Folder {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .cascade)
        var notes: [Note] = []

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .nullify)
        var notes: [Note] = []

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}
主なパターン
  • 完全なスナップショット 変更のないモデルも含め、すべてのモデルを含めます。
  • セマンティックバージョニング Schema.Version(major, minor, patch) を使用します。
  • 明示的な init SwiftData はイニシャライザーを自動生成しません。
  • 逆方向リレーションシップ 双方向にするには、両側で指定します。

パターン 2:型変更のための 2 段階移行

使用する場合 プロパティ型を変更するとき(String → AttributedString、Int → String など)

問題

Note.content を String から AttributedString に変更したいのですが、古い型と新しい型の両方に同時にアクセスすることはできません。

ソリューション

両方のプロパティを持つ中間スキーマバージョン(V1.1)を使用します。

// Stage 1: V1 → V1.1 (Add new property alongside old)
enum NotesSchemaV1_1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 1, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // OLD property (to be deprecated)
        @Attribute(originalName: "content")
        var contentOld: String = ""

        // NEW property (target type)
        var contentNew: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, contentOld: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.contentOld = contentOld
            self.createdAt = createdAt
        }
    }

    // Folder and Tag unchanged (copy from V1)
    @Model final class Folder { /* same as V1 */ }
    @Model final class Tag { /* same as V1 */ }
}

// Stage 2: V1.1 → V2 (Transform data, remove old property)
enum NotesSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // Renamed from contentNew
        @Attribute(originalName: "contentNew")
        var content: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: AttributedString?, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model final class Folder { /* same as V1 */ }
    @Model final class Tag { /* same as V1 */ }
}
移行計画
enum NotesMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV1_1, migrateV1_1toV2]
    }

    // Stage 1: Lightweight migration (adds contentNew)
    static let migrateV1toV1_1 = MigrationStage.lightweight(
        fromVersion: NotesSchemaV1.self,
        toVersion: NotesSchemaV1_1.self
    )

    // Stage 2: Custom migration (transform String → AttributedString)
    static let migrateV1_1toV2 = MigrationStage.custom(
        fromVersion: NotesSchemaV1_1.self,
        toVersion: NotesSchemaV2.self,
        willMigrate: { context in
            // Transform data while we still have access to V1.1 models
            var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()

            // Prefetch relationships to preserve them
            fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

            let notes = try context.fetch(fetchDesc)

            for note in notes {
                // Convert String → AttributedString
                note.contentNew = try? AttributedString(markdown: note.contentOld)
            }

            try context.save()
        },
        didMigrate: nil
    )
}
移行計画を適用する
@main
struct NotesApp: App {
    let container: ModelContainer = {
        do {
            let schema = Schema(versionedSchema: NotesSchemaV2.self)
            return try ModelContainer(
                for: schema,
                migrationPlan: NotesMigrationPlan.self
            )
        } catch {
            fatalError("Failed to create container: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

パターン 3:多対多リレーションシップの移行

使用する場面: 多対多リレーションシップがある場合(タグ ↔ ノート)

重要な要件
  1. 逆リレーションシップを明示する SwiftData は多対多を推論しません
  2. 両側に配列を使用する 省略可能ではなく、配列である必要があります
  3. iOS 17.0 のバグへの回避策 アルファベット順の命名に関する問題
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // Many-to-many: MUST specify inverse
        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []  // ✅ Array with default value

        init(id: String, title: String) {
            self.id = id
            self.title = title
        }
    }

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        // Many-to-many: MUST specify inverse
        @Relationship(deleteRule: .nullify, inverse: \Note.tags)
        var notes: [Note] = []  // ✅ Array with default value

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}
iOS 17.0 のアルファベット順に関するバグへの回避策

iOS 17.0 では、モデル名がアルファベット順の場合(例:Actor ↔ Movie は動作しますが、Movie ↔ Person は失敗します)、多対多リレーションシップが失敗することがあります。

回避策: リレーションシップ配列にデフォルト値を設定します。

@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = []  // ✅ Default value prevents bug

修正済み: iOS 17.1 以降

中間テーブルのメタデータを追加する

リレーションシップに追加のフィールド(例:「このタグはいつ追加されましたか?」)が必要な場合は、明示的な中間モデルを使用します。

@Model
final class NoteTag {
    @Attribute(.unique) var id: String
    var addedAt: Date  // Metadata on relationship

    @Relationship(deleteRule: .cascade)
    var note: Note?

    @Relationship(deleteRule: .cascade)
    var tag: Tag?

    init(id: String, note: Note, tag: Tag, addedAt: Date) {
        self.id = id
        self.note = note
        self.tag = tag
        self.addedAt = addedAt
    }
}

@Model
final class Note {
    @Attribute(.unique) var id: String
    var title: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // One-to-many to junction

    var tags: [Tag] {
        noteTags.compactMap { $0.tag }
    }
}

@Model
final class Tag {
    @Attribute(.unique) var id: String
    var name: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // One-to-many to junction

    var notes: [Note] {
        noteTags.compactMap { $0.note }
    }
}

パターン 4:移行中のリレーションシップの事前読み込み

使用する場面: N+1 クエリを避けるため、リレーションシップのあるモデルを移行する場合

static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        var fetchDesc = FetchDescriptor<SchemaV1.Note>()

        // Prefetch relationships (iOS 26+)
        fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

        // Only fetch properties you need (iOS 26+)
        fetchDesc.propertiesToFetch = [\.title, \.content]

        let notes = try context.fetch(fetchDesc)

        // Relationships are already loaded - no N+1
        for note in notes {
            let folderName = note.folder?.name  // ✅ Already in memory
            let tagCount = note.tags.count  // ✅ Already in memory
        }

        try context.save()
    },
    didMigrate: nil
)
パフォーマンスへの影響
Without prefetching:
- 1 query to fetch notes
- N queries to fetch each note's folder
- N queries to fetch each note's tags
= 1 + N + N queries

With prefetching:
- 1 query to fetch notes
- 1 query to fetch all folders
- 1 query to fetch all tags
= 3 queries total

パターン 5:プロパティの名前変更

使用する場面: データを失わずにプロパティの名前を変更する場合

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String  // Original name
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String

        // Renamed from "title" to "heading"
        @Attribute(originalName: "title")
        var heading: String
    }
}

// Migration plan (lightweight migration)
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

この方法が機能する理由: SwiftData は originalName を認識し、軽量移行中にデータを保持します。


パターン 6:一意制約に対する重複排除

使用する場面: 重複があるフィールドに @Attribute(.unique) を追加する場合

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        var name: String  // ❌ Not unique, has duplicates

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        @Attribute(.unique) var name: String  // ✅ Now unique

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Deduplicate before adding unique constraint
            let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())

            var seenNames = Set<String>()
            for trip in trips {
                if seenNames.contains(trip.name) {
                    // Duplicate - delete or rename
                    context.delete(trip)
                } else {
                    seenNames.insert(trip.name)
                }
            }

            try context.save()
        },
        didMigrate: nil
    )
}

移行のテスト

必須テストチェックリスト
  • 新規インストールをテストする(すべての移行が V1 → 最新版まで実行されること)
  • 各旧バージョンからのアップグレードをテストする
  • 実機でテストする(シミュレーターだけでは不十分です)
  • 移行後のリレーションシップの整合性を確認する
  • データ損失がないことを確認する(前後のレコード数を比較する)
  • 本番環境と同規模のデータセットでテストする
シミュレーターでのテストだけでは不十分な理由

シミュレーターの動作: 再ビルド時にデータベースが削除され、常に新しいスキーマが読み込まれます

実機の動作: アップデート後も永続データベースが保持されるため、スキーマが一致している必要があります

// ❌ WRONG - only testing in simulator
// You rebuild → simulator deletes database → fresh install
// Migration code never runs!

// ✅ CORRECT - test on real device
// 1. Install v1 build on device
// 2. Create sample data
// 3. Install v2 build (with migration)
// 4. Verify data preserved
テストのワークフロー

本番環境に移行をデプロイする前に:

1. テストデータセットを作成する

移行前の状態を表すテストデータを準備します:

  • 最小データセット - すべてのリレーションシップタイプを含む 10~20 件のレコード
  • 現実的なデータセット - 本番環境の規模に相当する 1,000 件以上のレコード
  • エッジケース - 空のリレーションシップ、リレーションシップ数の上限、省略可能なフィールド
2. シミュレーターでテストする

テストデータを使って移行を実行します:

// Create test data in V1 schema
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... populate test data ...

// Run migration
let v2Container = try ModelContainer(
    for: Schema(versionedSchema: SchemaV2.self),
    migrationPlan: MigrationPlan.self
)

以下を確認します:

  • すべてのリレーションシップが保持されている
  • データ損失がない(前後のレコード数を比較する)
  • 新しいフィールドに正しく値が設定されている
  • 現実的なデータセットのサイズでパフォーマンスに問題がない
3. 実機でテストする

重要: シミュレーターでの成功は、本番環境での安全性を保証しません。

# Workflow:
1. Install v1 build on real device
2. Create 100+ records with relationships
3. Verify data exists
4. Install v2 build (over existing app, don't delete)
5. Launch app
6. Verify:
   - App launches without crash
   - All 100+ records still exist
   - Relationships intact
   - New fields populated
4. 本番データで検証する(可能な場合)

本番データにアクセスできる場合:

  • 本番データベースを開発環境にコピーする
  • コピーに対して移行を実行する
  • データ破損がないことを確認する
  • 本番環境と同規模のデータセットでパフォーマンスを確認する

移行に失敗した場合のデバッグツールについては、axiom-swiftdata-migration-diag を参照してください。

移行テストのパターン
import Testing
import SwiftData

@Test func testMigrationFromV1ToV2() throws {
    // 1. Create V1 data
    let v1Schema = Schema(versionedSchema: SchemaV1.self)
    let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
    let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)

    let context = v1Container.mainContext
    let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
    context.insert(note)
    try context.save()

    // 2. Run migration to V2
    let v2Schema = Schema(versionedSchema: SchemaV2.self)
    let v2Container = try ModelContainer(
        for: v2Schema,
        migrationPlan: MigrationPlan.self,
        configurations: v1Config
    )

    // 3. Verify data migrated
    let v2Context = v2Container.mainContext
    let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())

    #expect(notes.count == 1)
    #expect(notes.first?.content != nil)  // String → AttributedString
}

判断フロー:軽量移行とカスタム移行

What change are you making?
├─ Adding optional property → Lightweight ✓
├─ Adding required property with default → Lightweight ✓
├─ Renaming property (with originalName) → Lightweight ✓
├─ Removing property → Lightweight ✓
├─ Changing relationship delete rule → Lightweight ✓
├─ Adding new model → Lightweight ✓
├─ Changing property type → Custom (two-stage) ✗
├─ Making optional → required → Custom (populate nulls first) ✗
├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗
└─ Complex relationship restructure → Custom ✗

よくある間違い

❌ VersionedSchema にすべてのモデルを含め忘れる
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self]  // ❌ WRONG: Missing Folder and Tag
    }
}

// ✅ CORRECT: Include ALL models
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // ✅ Even if unchanged
    }
}

理由: 各 VersionedSchema はデータモデルの差分ではなく、完全なスナップショットです。


❌ didMigrate で旧モデルにアクセスしようとする
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        // ❌ CRASH: SchemaV1.Note doesn't exist here
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    }
)

// ✅ CORRECT: Use willMigrate for old models
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ SchemaV1.Note exists here
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    },
    didMigrate: nil
)

❌ 実データを使って実機でテストしない
// ❌ WRONG: Simulator success ≠ production safety
// Rebuild simulator → database deleted → fresh install
// Migration never actually runs!

// ✅ CORRECT: Test migration path
// 1. Install v1 on real device
// 2. Create data (100+ records)
// 3. Install v2 with migration
// 4. Verify data preserved

❌ 逆リレーションシップを明示せずに多対多を設定する
// ❌ WRONG: SwiftData can't infer many-to-many
@Model
final class Note {
    var tags: [Tag] = []  // ❌ Missing inverse
}

// ✅ CORRECT: Explicit inverse
@Model
final class Note {
    @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
    var tags: [Tag] = []  // ✅ Inverse specified
}

❌ シミュレーターでの成功を本番環境での成功と見なす

シミュレーターでは再ビルド時にデータベースが削除されます。実機ではアップデート後も永続データベースが保持されます。

影響: シミュレーターでは移行のバグが隠れたままになり、本番ユーザー全員でクラッシュします。

対処法: リリース前に必ず実機でテストしてください。


失敗した移行のデバッグ

Core Data SQL デバッグを有効にする
# In Xcode scheme, add argument:
-com.apple.coredata.swiftdata.debug 1

出力 移行中に実際の SQL クエリを表示します

CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE
CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
よくあるエラーメッセージ
エラー考えられる原因修正方法
"Expected only Arrays for Relationships"多対多の逆方向の関連付けがありません@Relationship(inverse:) を追加します
"The model used to open the store is incompatible"スキーマのバージョンが一致しません移行プランの schemas 配列を確認します
"Failed to fulfill faulting for..."関連付けの整合性が崩れています移行中に関連付けを事前取得します
スキーマ変更後、アプリの起動時にクラッシュしますVersionedSchema にモデルがありませんすべてのモデルを含めます

クイックリファレンス

基本的な移行の設定
// 1. Define versioned schemas
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }

// 2. Create migration plan
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

// 3. Apply to container
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
    for: schema,
    migrationPlan: MigrationPlan.self
)

リソース

WWDC:2025-291、2023-10195

ドキュメント:/swiftdata

スキル:axiom-swiftdata、axiom-swiftdata-migration-diag、axiom-database-migration


作成日 2025-12-09 対象 iOS 17 以降(iOS 26 以降の機能を中心に扱います) フレームワーク SwiftData(Apple) Swift 5.9 以降

コメント