SwiftDataで異なるModelContext間の関連付けが引き起こすエラーと対処法
問題の概要
@Modelを使用して定義したモデル同士に@Relationshipで関連付けがある場合、異なるModelContextでそれらを操作すると、ランタイムでクラッシュやエラーが発生することがあります。
エラー例
Fatal error: attempting to relate model ...
Illegal attempt to establish a relationship between objects in different contexts)
再現コード例
import Foundation
import SwiftData
@Model
final class Category {
@Attribute(.unique) var id: UUID
var name: String
@Relationship(deleteRule: .cascade, inverse: \Item.category)
var items: [Item]
init(id: UUID, name: String, items: [Item] = []) {
self.id = id
self.name = name
self.items = items
}
}
@Model
final class Item {
@Attribute(.unique) var id: UUID
var title: String
var category: Category?
init(id: UUID, title: String, category: Category? = nil) {
self.id = id
self.title = title
self.category = category
}
}
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Category.self, Item.self, configurations: config)
// 異なる ModelContext を2つ作成
let contextA = ModelContext(container)
let contextB = ModelContext(container)
// contextA で Category を作る
let categoryA = Category(id: UUID(), name: "Some Category", items: [])
contextA.insert(categoryA)
try contextA.save()
// contextB で Item を作る
let itemB = Item(id: UUID(), title: "Some Item")
contextB.insert(itemB)
try contextB.save()
// 異なる context のオブジェクト同士を関連付ける
itemB.category = categoryA
try contextB.save() // ここでクラッシュ
@Relationshipを持つモデルは、片方に変更が入るともう片方もSwiftDataが自動で更新します。そのため、上記の例ほど分かりやすくない場面でもクラッシュする可能性があります。
原因としては、関連付けられたモデルの操作を別々のModelContextで行ったためです。
基本的な対処は 「同じModelContextで処理を完結させること」 です。
@Environment(\.modelContext)や@QueryをUIで使うだけなら通常は問題になりません。しかし、UI層からDBを隠蔽する設計を採用している場合、Repository内で安易にModelContextを生成すると、上記のようなエラーが起こり得ます。
解決策:staticなModelContextを使う
ModelContainerには、メインスレッドで動作するmainContextが最初から用意されています。これを使えば@Queryによる変更検知が機能しますが、UIとDBを切り離した設計を採用している場合は、@Queryを使わないことも多く、その場合はmainContextで実行する必要はありません。UIスレッドをブロックしてしまう可能性があるため、避けるべきです。(※これらSwiftUIと連携された標準APIがメインスレッドで実行されること前提であるのを考慮すると、単純なケースではmainContextで全て処理してしまうでも問題ないように思いますが)
一方で、ModelContext()を使ってコンテキストを生成すると、バックグラウンドスレッドで操作できます。これをstatic変数として共有すれば、先述のエラーは回避可能です。
ただし、スレッドセーフではありません。そのため、データ競合や不整合が発生するリスクがあります。
より安全な方法:@ModelActorを使う
ここで有効なのが@ModelActorです。@ModelActorには自動的にmodelContextが用意されるため、手動で初期化せず安全に利用できます。Repositoryのクラスやactorに@ModelActorを付与すれば、その内部では同一のModelContextをスレッドセーフに扱えます。
ただし、CategoryRepositoryやItemRepositoryのように複数のRepositoryごとに@ModelActorを付けると、それぞれが別のModelContextを持つことになり、再びエラーの原因になります。
したがって、@ModelActorを適用したactorを1つ定義し、それをstaticに共有するのが安全な設計となります。
@ModelActor
actor PersistentStoreActor {
static let shared = PersistentStoreActor(modelContainer: .appContainer)
// イニシャライザはModelActorが実装してくれるので不要
func withContext<T>(_ operation: @Sendable (ModelContext) throws -> T) async rethrows -> T {
try operation(modelContext)
}
}
//使用例
await PersistentStoreActor.shared.withContext { context in
let category = Category(id: UUID(), name: "Some Category")
let item = Item(id: UUID(), title: "Some Item")
context.insert(category)
context.insert(item)
item.category = category
try context.save()
}
注意点
アプリ全体で1つのactorを共有すると、すべてのトランザクションが同一ModelContextで処理されます。そのため、大量データ検索などでパフォーマンス面の問題が発生する可能性があります。
それを避けたい場合、個別にModelContextを生成する必要があります。その際は、そのコンテキストのライフサイクルとスコープを意識し、関連するモデルを同じスコープに収める設計が必要です。
例えば、DDDの考え方を取り入れて集約ルート単位でRepositoryを設計する方法を考えました。
ここでは詳しく説明しませんが、集約ルート(Aggregate Root)とは、関連するエンティティや値オブジェクトをひとまとまりに管理する境界のことです。
• 集約は「一緒に扱うべきモデルのグループ」を指し、
• 集約ルートはその中心となるモデルであり、外部からアクセスできる唯一の窓口です。
例えば「カテゴリとその配下のアイテム群」を1つの集約とみなし、Categoryを集約ルートとします。この場合、外部から直接Itemを操作するのではなく、必ずCategoryを通じて追加・削除・関連付けを行います。こうすることで「カテゴリとアイテムの関係」が一貫して正しく保たれます。
この考え方をSwiftDataに当てはめると、集約ルートとRepositoryを1対1で対応させる設計になります。そして、その集約内の操作は常に単一のModelContextで処理します(例:集約専用の@ModelActorが持つModelContextを利用する)。
これにより、異なる集約間ではModelContextの衝突を避けつつ、集約内では一貫性を維持できるようになります。
いずれにしても、ランタイムでエラーやクラッシュが起きるのは最も厄介でネックになる部分です。特にUIとDBを完全に切り離した設計でSwiftDataを使う場合には、こうしたエラーを静的に解決できる仕組みが欲しくなります。
まとめ
- @Relationshipを異なるModelContext間で扱うとクラッシュする
- 基本は「同じModelContextで処理を完結」させる
- 静的に解決するなら@ModelActorを利用し、sharedなactorを通して操作するのが安全
- 大規模処理では、ModelContextのスコープ設計も重要
Discussion