その他

SwiftDataでマイグレーションを行う

その他
この記事は約12分で読めます。

マイグレーションとは何か

データベースのスキーマ(構造や形式)をより最新のバージョンに変更する過程を、マイグレーション といいます。

要件の変更に対応するために、データベースの構造を更新し、既存のデータを新しい形式に変換する手順です。

SwiftDataにおけるマイグレーション

SwiftDataでは、VersionedSchemaSchemaMigrationPlanを使用することでマイグレーションを行うことができます。

マイグレーションは二段階あり、それぞれ「 lightweight migration 」と「complex migration 」と言います。

あるモデルにおいて、以前は一意でなかったプロパティにユニーク制約をつける際、「 lightweight migration 」を使用することができます。

ここで、新しいプロパティを追加したり、プロパティ名を変更する場合、「complex migration」を使用します。

「complex migration」においては、SwiftDataにモデルの古いバージョンと新しいバージョンの整合性を保つために、カスタムロジックを提供します。

マイグレーションの手順

手順は簡単です。

  1. マイグレーション対象のモデルのtypealiasとして最新のモデルを指定する
  2. 特定のバージョン間でどのようにマイグレーションするかを定義するためのインターフェースである、MigrationPlanを作成する
  3. マイグレーションの移行手法を定義する

上記の手順は、「 lightweight migration 」においても「complex migration 」においても同様です。

マイグレーションの実装

lightweight migration

例としてItemモデルを作成しました。プロパティとして <code>timestamp を持っています。

<code>VersionedSchema に準拠させます。
スキーマバージョンは 「1, 0, 0」です。

struct ItemSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        var timestamp: Date
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
}

Itemの <code>timestamp をユニークにしましょう。

ユニークな制約が適用された V2のバージョン化スキーマ を作成してみました。

struct ItemSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        // timestampに一意制約を付与する
        @Attribute(.unique) var timestamp: Date
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
}

この場合、lightweight migration で実現できます。
先ほど示した手順に則ってマイグレーションを行います。

まずはItemのtypealiasとして、ItemSchemaV2を指定します。

typealias Item = ItemSchemaV2.Item

次に、MigrationPlanを作成します。

schemas にて、データベーススキーマの異なるバージョンをリストすることで、スキーマのバージョン管理を行います。

MigrationStage にて、マイグレーションプロセスの具体的な手順を定義します。

enum ItemMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        // バージョンの順番に配置することで、SwiftDataが順序正しくバージョン間を移行できるようにする
        [ItemSchemaV1.self, ItemSchemaV2.self]
    }
    
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    // 簡易的なマイグレーション。V1toV2の時は、一意制約をつけただけなので、lightweightで十分。
    static let migrateV1toV2: MigrationStage = MigrationStage.lightweight(
        fromVersion: ItemSchemaV1.self,
        toVersion: ItemSchemaV2.self
    )
}

complex migration

Imageに新しいプロパティmessageimage を追加します。

struct ItemSchemaV3: VersionedSchema {
    static var versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        @Attribute(.unique) var timestamp: Date
        // メッセージ 写真を追加。
        var message: String = ""
        var image: String = ""
        init(timestamp: Date, message: String = "", image: String = "") {
            self.timestamp = timestamp
            self.message = message
            self.image = image
        }
    }
}

大筋は先ほど共有したlightweight migrationの手順と同一です。

違いは、MigrationStageにてデータの整合性を保つためのカスタムメソッドを実装する必要がある点です。

    // カスタムマイグレーション。V2toV3時は messageとitemが追加されているため、旧バージョンの際の扱いを定義する。
    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: ItemSchemaV2.self,
        toVersion: ItemSchemaV3.self,
        willMigrate: nil
    ) { context in
        let items = try? context.fetch(FetchDescriptor<ItemSchemaV3.Item>())
        
        items?.forEach { item in
            item.message = ""
            item.image = ""
        }
        
        try? context.save()
    }

ここでは、新しいバージョン(ItemSchemaV3)において追加された messageimage フィールドが旧バージョン(ItemSchemaV2)には存在しないため、各アイテムの messageimage プロパティを空文字列(””)で初期化しています。

ここまでのコードをまとめると、以下の状態になります。コピペすればマイグレーションの挙動を確認できます。

import Foundation
import SwiftData

struct ItemSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        var timestamp: Date
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
}


struct ItemSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        // timestampに一意制約を付与する
        @Attribute(.unique) var timestamp: Date
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
}

struct ItemSchemaV3: VersionedSchema {
    static var versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Item.self]
    }
    @Model
    final class Item {
        @Attribute(.unique) var timestamp: Date
        // メッセージ 写真カラムを追加。
        var message: String = ""
        var image: String = ""
        init(timestamp: Date, message: String = "", image: String = "") {
            self.timestamp = timestamp
            self.message = message
            self.image = image
        }
    }
}


// Itemが常に最新バージョンを指すように型エイリアスを追加する
typealias Item = ItemSchemaV3.Item

// マイグレーションプランを作成する。
// 一つの特定のバージョンから別のバージョンに移動する方法を定義する
enum ItemMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        // バージョンの順番に配置することで、SwiftDataが順序正しくバージョン間を移行できるようにする
        [ItemSchemaV1.self, ItemSchemaV2.self, ItemSchemaV3.self]
    }
    
    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }
    // 簡易的なマイグレーション。V1toV2の時は、一意制約をつけただけなので、lightweightで十分。
    static let migrateV1toV2: MigrationStage = MigrationStage.lightweight(
        fromVersion: ItemSchemaV1.self,
        toVersion: ItemSchemaV2.self
    )
    // カスタムマイグレーション。V2toV3時は messageとitemが追加されているため、旧バージョンの際の扱いを定義する。
    static let migrateV2toV3 = MigrationStage.custom(
        fromVersion: ItemSchemaV2.self,
        toVersion: ItemSchemaV3.self,
        willMigrate: nil
    ) { context in
        let items = try? context.fetch(FetchDescriptor<ItemSchemaV3.Item>())
        
        items?.forEach { item in
            item.message = ""
            item.image = ""
        }
        
        try? context.save()
    }
}

参考

Model your schema with SwiftData - WWDC23 - Videos - Apple Developer
Learn how to use schema macros and migration plans with SwiftData to build more complex features for your app. We'll show you how to...
VersionedSchema | Apple Developer Documentation
An interface for describing a specific version of a schema, including the models it contains.
SchemaMigrationPlan | Apple Developer Documentation
An interface for describing the evolution of a schema and how to migrate between specific versions.
MigrationStage | Apple Developer Documentation
Describes a migration between two versions of the same schema.