💾

SwiftData 入門

2023/11/11に公開

はじめに

本記事は2023年11月に開催された技術書典 15第九回技術書同人誌博覧会にて無料で配布した ゆめみ大技林 '23 (2) に寄稿している記事のウェブ版です。

SwiftData とは

SwiftData は、WWDC 2023 で発表された新しいデータ永続化のフレームワークです。

iOS 17 から使用可能で、 Swift 5.9 からサポートされた Swift Macros を使用することでモデル定義の簡略化、SwiftUI とのシームレスな連携を実現しています。

また CoreData の .xcdatamodel ファイルのような外部ファイルフォーマットは必要なく、コードのみで実装可能なことも特徴の一つです。

本稿のカバー範囲

本稿は SwiftData のモデル定義とモデルの挿入、削除、更新、取得などの基本的な操作を解説した入門記事です。SwiftData の初心者向けに、基本的な使用方法と特徴を説明します。

この記事では SwiftUI 関係の機能には触れずに、純粋に SwiftData の挙動のみを解説します。そのため、@Environment(\.modelContext)@Queryといった SwiftUI との連携に関連するトピックには触れていません。

動作確認環境

Xcode 15.0.1(15A507)

サンプルコード

https://github.com/yusuga/swiftdata-101

主な登場人物

DB 関係

用途 特徴
Schema アプリのモデルのバージョンをカプセル化し、異なるモデルバージョン間での切り替えをサポートする 各バージョンごとに使用するモデルの型を指定して初期化する
ModelConfiguration スキーマの永続性を制御し、データの保存場所を指定する データを保存するストレージの Path を設定できる
ModelContainer アプリのスキーマとモデルストレージの設定を管理し、データベースを生成するオブジェクト 複数の ModelConfiguration を設定して制御できる
ModelContext データの挿入、削除、取得、保存およびディスクへの変更を管理するオブジェクト insert, fetch, save メソッドがあり、モデルの更新と取得ができる

モデル定義関係

用途
@Model マクロ class に @Model マクロを使うことで SwiftData として保存可能なモデルクラスになる
@Attribute マクロ ユニーク制約などプロパティに制約を定義する
@Relationship マクロ モデル間のリレーション(関連)を定義する
PersistentModel SwiftData のモデルとして定義された class を管理するためのインタフェース(Protocol)
PersistentIdentifier SwiftData モデル を一意に識別する ID

Fetch 関係

用途
FetchDescriptor モデルの型、述語、および並べ替え順序の指定をする
#Predicate マクロ Predicate を生成するためのマクロ
Predicate モデルに対してさまざまなフィルタリングを記述する

ModelContext の生成と一連の操作

チートシート(初期化と各種操作)

モデルの保存や取得をするためには ModelContext が必要です。 ModelContext は次の手順で生成します。

  1. @Model で定義した class の型で Schema を初期化
  2. SchemaModelConfiguration を初期化
  3. ModelConfigurationModelContainer を初期化
  4. ModelContainerModelContext を初期化

ModelContext の初期化と各種操作で最低限必要なコードは以下です。

import SwiftData

// class に `@Model` を定義すれば SwiftData で保存可能なモデルになる
@Model
final class SimpleItem {
  
  var value: Int
  
  init(value: Int) {
    self.value = value
  }
}

// 1. Model 定義の型情報で Schema を初期化
let schema = Schema([SimpleItem.self])
// 2. Schema で ModelConfiguration を初期化
let modelConfiguration = ModelConfiguration(schema: schema)
// 3. ModelConfiguration で ModelContainer で初期化
let modelContainer = try ModelContainer(
  for: SimpleItem.self,
  configurations: modelConfiguration
)
// 4. ModelContainer で ModelContext で初期化
let modelContext = ModelContext(modelContainer)

// ModelContext.autosaveEnabled が有効になっていると
// 現在のメインスレッドの RunLoop が終わったタイミングなどで自動的に保存されます
// デフォルト値は true です
modelContext.autosaveEnabled = true

// モデルを初期化
let item = SimpleItem(value: 1)

// モデルを追加
modelContext.insert(item)
try modelContext.save() // 明示的な保存

// モデルの更新
item.value = 2
try modelContext.save()

// モデルの取得
let fetchDescriptor = FetchDescriptor<SimpleItem>()
let fetchedItems = try modelContext.fetch(fetchDescriptor)

// モデルの削除
modelContext.delete(item)
try modelContext.save()

本稿用の便利メソッド

本稿では ModelContext の生成を簡略化するために ModelContext(for:storageType:shouldDeleteOldFile:fileName:) を独自で定義して使用します。

public extension ModelContext {  
  enum StorageType {
    case inMemory
    case file
  }
  
  convenience init(
    for types: any PersistentModel.Type...,
    storageType: StorageType = .inMemory,
    shouldDeleteOldFile: Bool = true,
    fileName: String = #function
  ) throws {
    // 1. モデル定義のメタタイプで Schema を初期化
    let schema = Schema(types)
    
    let sqliteURL = URL.documentsDirectory
      .appending(component: fileName)
      .appendingPathExtension("sqlite")
    
    // ファイルストレージの DB を削除するかで
    // これは動作確認をする上で設けています。
    if shouldDeleteOldFile {
      let fileManager = FileManager.default
      
      if fileManager.fileExists(atPath: sqliteURL.path) {
        try fileManager.removeItem(at: sqliteURL)
      }
    }
    
    // 2. Schema で ModelConfiguration を初期化
    let modelConfiguration: ModelConfiguration = {
      switch storageType {
      case .inMemory:
        // ファイルストレージを使用せずにメモリのみで SwiftData を扱う
        ModelConfiguration(
          schema: schema,
          isStoredInMemoryOnly: true
        )
      case .file:
        // ファイルストレージに永続化するための url を指定
        ModelConfiguration(
          schema: schema,
          url: sqliteURL
        )
      }
    }()
    
    // 3. ModelConfiguration で ModelContainer を初期化
    let modelContainer = try ModelContainer(
      for: schema,
      configurations: [modelConfiguration]
    )
    
    // 4. ModelContainer で ModelContext を初期化
    self.init(modelContainer)
  }
}

fetch 系の操作についても ModelContext.fetch(for:) のような簡略化したコードを使用します。

public extension ModelContext {
  func fetch<Model>(
    for type: Model.Type
  ) throws -> [Model] where Model: PersistentModel {
    try fetch(.init())
  }
  
  func fetchCount<Model>(
    for type: Model.Type
  ) throws -> Int where Model: PersistentModel {
    try fetchCount(FetchDescriptor<Model>())
  }
}

3種類の保存方法

保存には 3 種類の方法があります。

デフォルト設定では ModelContext.autosaveEnabled が true になっているため、通常利用では保存を意識することなく自動で保存されます。ただし、バックグラウンドで生成した ModelContext は自動保存されないため注意が必要です(詳しくは後述)。

1. ModelContext.save()

ModelContext.save() で明示的に保存できます。

let context = try ModelContext(
  for: SimpleItem.self,
  storageType: .file
)
context.autosaveEnabled = false // 挙動を明確にするために false に変更

// insert
context.insert(
  SimpleItem(value: 1)
)

// save 前だが context にはモデルが1つあることを確認できる
XCTAssertEqual(
  try context.fetchCount(for: SimpleItem.self),
  1
)

// ただし、異なる context で fetch すると 0 件になる。
// これはまだファイルストレージに変更が反映されていなことを意味する。
XCTAssertEqual(
  try ModelContext(
    for: SimpleItem.self,
    storageType: .file,
    shouldDeleteOldFile: false
  )
  .fetchCount(for: SimpleItem.self),
  0
)

// 保存
try context.save()

// save 後ならファイルストレージに保存されていることを確認できる
XCTAssertEqual(
  try ModelContext(
    for: SimpleItem.self,
    storageType: .file,
    shouldDeleteOldFile: false
  )
  .fetchCount(for: SimpleItem.self),
  1
)

2. ModelContext.transaction(block:)

前述の ModelContext.save() と似ていますが、ModelContext.transaction(block:) のクロージャを抜けるタイミングで保存されます。

let context = try ModelContext(for: SimpleItem.self, storageType: .file)
context.autosaveEnabled = false // 挙動を明確にするために false に変更

let item = SimpleItem(value: 1)
try context.transaction {
  context.insert(item)
  
  // トランザクション内だがすでに context にモデルが追加されていることが確認できる
  XCTAssertEqual(
    try context.fetchCount(for: SimpleItem.self),
    1
  )

  // まだファイルストレージに保存されていないことを確認
  XCTAssertEqual(
    try ModelContext(
      for: SimpleItem.self,
      storageType: .file,
      shouldDeleteOldFile: false
    )
    .fetchCount(for: SimpleItem.self),
    0
  )
}

// モデルが保存されたことを確認できる
XCTAssertEqual(
  try context.fetchCount(for: SimpleItem.self),
  1
)

// 異なる context からもモデルがあることを確認できる
XCTAssertEqual(
  try ModelContext(
    for: SimpleItem.self,
    storageType: .file,
    shouldDeleteOldFile: false
  )
  .fetchCount(for: SimpleItem.self),
  1
)

3. ModelContext.autosaveEnabled

ModelContext は自動で保存可能な仕組みが実装されています。自動保存を有効にするかは ModelContext.autosaveEnabled プロパティで設定可能です。デフォルト値は true です。

自動保存のタイミングはドキュメント化されていないのですが、次のタイミングで保存されていそうです(参考: When does SwiftData autosave data?)。

  • メインスレッドの RunLoop が終わるタイミング
  • アプリがバックグラウンドに遷移したタイミング
  • アプリがフォアグラウンドに遷移したタイミング

メインスレッドの RunLoop が終わるタイミングで保存されていることを検証したコードが次になります。

let context = try ModelContext(for: SimpleItem.self, storageType: .file)
context.autosaveEnabled = true // default も true になっている

context.insert(SimpleItem(value: 1))

XCTAssertEqual(
  try context.fetchCount(for: SimpleItem.self),
  1
)

// まだファイルストレージに保存されていないことを確認
XCTAssertEqual(
  try ModelContext(
    for: SimpleItem.self,
    storageType: .file,
    shouldDeleteOldFile: false
  )
  .fetchCount(for: SimpleItem.self),
  0
)

let expectation = expectation(description: "Wait for next RunLoop")

// 次の RunLoop を確認
DispatchQueue.main.async {
  XCTAssertNoThrow(
    {
      // save を読み出すことなく保存されていることが確認できる
      try XCTAssertEqual(
        ModelContext(
          for: SimpleItem.self,
          storageType: .file,
          shouldDeleteOldFile: false
        )
        .fetchCount(for: SimpleItem.self),
        1
      )
    }
  )
  expectation.fulfill()
}

wait(for: [expectation])

この設計はとても秀逸で、通常利用ですと MainActor で ModelContext を扱ってさえいれば保存タイミングを意識することなく SwiftUI への動的な反映と永続化ができます。

DispatchQueue.main 以外に RunLoop(思った挙動ではなかった…)と MainActor の検証もあります。

その他: バックグラウンドスレッドで保存

ModelContext をバックグラウンドスレッドで生成してその中でモデルの操作をすればバックグラウンドスレッドからデータベースを更新できます。

注意点はバックグラウンドスレッドの ModelContext では Autosave が動作しません。そのため明示的に保存する必要があります。

final class OperationTests: XCTestCase {

  func testSaveInBackground() async throws {
    // test メソッドは async をつけるとバックグラウンドスレッドで実行される
    XCTAssertFalse(Thread.isMainThread)
    
    let context = try ModelContext(for: SimpleItem.self, storageType: .file)
    context.autosaveEnabled = false
    
    let item = SimpleItem(value: 1)
    context.insert(item)
    
    XCTAssertEqual(try context.fetchCount(for: SimpleItem.self), 1)
    
    try await MainActor.run {
      // まだファイルストレージに保存されていないことを確認
      XCTAssertEqual(
        try ModelContext(
          for: SimpleItem.self,
          storageType: .file,
          shouldDeleteOldFile: false
        )
        .fetchCount(for: SimpleItem.self),
        0
      )
    }
    
    try context.save()
    
    // save 後にファイルストレージに保存されていることが確認できる
    XCTAssertEqual(
      try ModelContext(
        for: SimpleItem.self,
        storageType: .file,
        shouldDeleteOldFile: false
      )
      .fetchCount(for: SimpleItem.self),
      1
    )
    
    try await MainActor.run {
      // メインスレッドの context からも保存されていることを確認できる
      XCTAssertEqual(
        try ModelContext(
          for: SimpleItem.self,
          storageType: .file,
          shouldDeleteOldFile: false
        )
        .fetchCount(for: SimpleItem.self),
        1
      )
    }
  }
}

モデルの操作

insert / モデルの追加

モデルの追加は ModelContext.insert(_:) を使用します。

let context = try ModelContext(for: SimpleItem.self)
    
let item = SimpleItem(value: 1)
context.insert(item)
try context.save()

update / モデルの更新

モデルの更新にはトランザクションは不要で直接モデルを更新可能です。その後は Autosave や明示的な save を行うことで永続化されます。

let context = try ModelContext(for: SimpleItem.self, storageType: .file)
  
let item = SimpleItem(value: 1)
context.insert(item)
try context.save()
  
// トランザクションは不要で値の変更が可能
item.value = 2        
try context.save()    

upsert / 一意なモデルの更新

一意な id をもつモデルに対しては insert で値を更新できます。

@Attribute(.unique) で一意制約がついた id を定義できます。詳細は後述 属性の設定 / @Attribute マクロ を参照してください。

@Model
final class UniqueItem {
  
  @Attribute(.unique) var id: Int
  var value: String
  
  init(id: Int, value: String) {
    self.id = id
    self.value = value
  }
}

一意制約があるモデルを重複して insert は可能です。加えて save するまでは内部的にはその両方が保持されています。その後 save すると後に追加したモデルの値が反映されて永続化されますので、insert を使用して upsert 相当のことができます。

let context = try ModelContext(for: UniqueItem.self)
      
do {
  let newItem = UniqueItem(id: 1, value: "a")
  context.insert(newItem)
  try context.save()
  
  XCTAssertEqual(
    try context.fetchCount(for: UniqueItem.self), 1
  )
  XCTAssertEqual(
    try context.fetch(for: UniqueItem.self).first?.value,
    newItem.value
  )
  
  // 同一 id で value が異なるモデルを insert する
  let updatedItem = UniqueItem(id: newItem.id, value: "b")
  context.insert(updatedItem)
  
  // 保存前の context には一時的に2つのオブジェクトが含まれていて fetch も可能
  let items = try context.fetch(
    for: UniqueItem.self,
    sortBy: [.init(\UniqueItem.value)]
  )
  XCTAssertEqual(items.count, 2)
  XCTAssertEqual(items[0].value, newItem.value)
  XCTAssertEqual(items[1].value, updatedItem.value)
  
  // insertedModelsArray には updatedItem が含まれている
  XCTAssertEqual(context.insertedModelsArray.count, 1)
  XCTAssertEqual(
    (context.insertedModelsArray[0] as? UniqueItem)?.value,
    updatedItem.value
  )
}

try context.save()

do {
  // 保存後はモデルは1つになり、 value も更新されている
  let items = try context.fetch(for: UniqueItem.self)
  XCTAssertEqual(items.count, 1)
  XCTAssertEqual(items.first?.value, "b")
  XCTAssertTrue(context.insertedModelsArray.isEmpty)
}

delete / モデルの削除

モデルのインスタンスから削除する

ModelContext.delete(_:) を使用します。

let context = try ModelContext(for: SimpleItem.self, storageType: .file)

let item = SimpleItem(value: 1)
context.insert(item)
try context.save()

context.delete(item)
try context.save()

// すでに削除された item を削除しようとしてもエラーはスローされない
context.delete(item)
try context.save()

// 異なる context で存在しない item を削除しようとしてもエラーはスローされない
let otherContext = try ModelContext(for: SimpleItem.self, storageType: .file, shouldDeleteOldFile: false)
otherContext.delete(SimpleItem(value: 1))
try otherContext.save()  

削除するモデルを Predicate で指定する

ModelContext.delete(model:where:includeSubclasses:) を使用すると条件を指定した削除が可能です。パラメータの Predicate を指定しなければ指定した model がすべて削除されます。

let context = try ModelContext(for: SimpleItem.self, storageType: .file)

let item1 = SimpleItem(value: 1)
let item2 = SimpleItem(value: 2)
context.insert(item1)
context.insert(item2)
try context.save()

// value が 1 のモデルだけ削除
try context.delete(
  model: SimpleItem.self,
  where: #Predicate {
    $0.value == 1
  }
)

fetch / モデルの取得

Predicate を指定してモデルを取得

FetchDescriptor で条件を指定して ModelContext.fetch(_:) します。

let context = try ModelContext(for: SimpleItem.self)

let count: Int = 10

// モデルを 10 個 insert する
(0..<count).forEach {
  context.insert(
    SimpleItem(value: $0)
  )
}
try context.save()

// FetchDescriptor で predicate を省略すると全件取得になる
let fetchDescriptor = FetchDescriptor<SimpleItem>()

// 取得
let allItems = try context.fetch(fetchDescriptor)
let allCount = try context.fetchCount(fetchDescriptor)

// 条件を指定して fetch
let filteredItems = try context.fetch(
  FetchDescriptor<SimpleItem>(
    predicate: #Predicate {
      $0.value == 5
    }
  )
)

ページネーションでモデルを取得

FetchDescriptor は var fetchOffset: Int?var fetchLimit: Int? をサポートしているためページネーションでモデルを取得できます。

let context = try ModelContext(for: UniqueItem.self)

// 100 件モデルを追加
(0..<100).forEach {
  let newItem = UniqueItem(id: $0, value: $0.description)
  context.insert(newItem)
}
try context.save()

// sort を指定しないと順不同で値が返ってきてしまう
let sort = SortDescriptor(\UniqueItem.id)

do {
  // id が 0 〜 9 のモデルを取得
  var fetchDescriptor = FetchDescriptor<UniqueItem>(sortBy: [sort])
  fetchDescriptor.fetchOffset = 0
  fetchDescriptor.fetchLimit = 10
  
  let items = try context.fetch(fetchDescriptor)
}

do {
  // id が 10 〜 19 のモデルを取得
  var fetchDescriptor = FetchDescriptor<UniqueItem>(sortBy: [sort])
  fetchDescriptor.fetchOffset = 10
  fetchDescriptor.fetchLimit = 10
  
  let items = try context.fetch(fetchDescriptor)
}

count / モデル数の取得

fetch とは別に専用の ModelContext.fetchCount(_:) が用意されています。

let context = try ModelContext(for: SimpleItem.self)
let count = 100

(0..<count).forEach {
  let newItem = SimpleItem(value: $0)
  context.insert(newItem)
}
try context.save()

// モデル数を取得
let count = try context.fetchCount(
  FetchDescriptor<SimpleItem>()
)

モデル定義(テーブル定義)

SwiftData で管理するデータのモデルを定義します。モデル定義は一般的なデータベースのテーブル定義に相当します。

一般的な型をプロパティで定義する

import SwiftData を記述し、 class 定義に @Model マクロを追加します。これだけで SwiftData として取り扱い可能なモデルとなります。このように @Model を除いたら通常のクラス定義を何ら変わらないところが SwiftData の素晴らしいところです。

注意点としては @Model マクロは class にのみ定義可能で、struct や enum に定義しようとするとコンパイルエラーになります。

import SwiftData

@Model
final class SimpleItem {
  
  var value: Int
  
  init(value: Int) {
    self.value = value
  }
}

@Model マクロが生成するコード

@Model がどんなコードを生成しているかは、 @Model の部分を右クリックして Expand Macro を選択すると次のようにコードが展開されます。

Swift Macros によって多くのコードが追加されていますが、永続化処理で内部的に使われているコードになるため、基本的にはこの追加されたコードを意識する必要はないです。

@Model
final class SimpleItem {
  
  var value: Int
  
  init(value: Int) {
    self.value = value
  }
  
  @Transient
  private var _$backingData: any SwiftData.BackingData<SimpleItem> = SimpleItem.createBackingData()

  public var persistentBackingData: any SwiftData.BackingData<SimpleItem> {
    get {
      _$backingData
    }
    set {
      _$backingData = newValue
    }
  }

  static var schemaMetadata: [SwiftData.Schema.PropertyMetadata] {
    return [
      SwiftData.Schema.PropertyMetadata(
        name: "value", 
        keypath: \SimpleItem.value, defaultValue: nil, metadata: nil
      )
    ]
  }

  init(backingData: any SwiftData.BackingData<SimpleItem>) {
    _value = _SwiftDataNoType()
    self.persistentBackingData = backingData
  }

  @Transient
  private let _$observationRegistrar = Observation.ObservationRegistrar()

  struct _SwiftDataNoType {
  }
}

extension SimpleItem: SwiftData.PersistentModel {
}

extension SimpleItem: Observation.Observable {
}

さまざまな型のプロパティを定義

次はさまざまなプロパティを定義した例です。

@Model
final class VariousTypesItem {
  
  var string: String
  var int: Int
  var double: Double
  var decimal: Decimal
  var bool: Bool
  var date: Date
  var data: Data
  var uuid: UUID
  var strings: [String]
  var ints: [Int]
  
  init(string: String, int: Int, double: Double, decimal: Decimal, bool: Bool, date: Date, data: Data, uuid: UUID, strings: [String], ints: [Int]) {
    self.string = string
    self.int = int
    self.double = double
    self.decimal = decimal
    self.bool = bool
    self.date = date
    self.data = data
    self.uuid = uuid
    self.strings = strings
    self.ints = ints
  }
}

オプショナル型をプロパティに定義する

オプショナルも定義可能です。 [String]?[String?] のどちらも定義可能なのが素晴らしいです。

@Model
final class OptionalItem {
  
  var string: String?
  var int: Int?
  var double: Double?
  var decimal: Decimal?
  var bool: Bool?
  var date: Date?
  var data: Data?
  var uuid: UUID?
  var strings: [String]?
  var ints: [Int]?
  var optionalStrings: [String?]
  var optionalInts: [Int?]
  
  init(string: String? = nil, int: Int? = nil, double: Double? = nil, decimal: Decimal? = nil, bool: Bool? = nil, date: Date? = nil, data: Data? = nil, uuid: UUID? = nil, strings: [String]? = nil, ints: [Int]? = nil, optionalStrings: [String?], optionalInts: [Int?]) {
    self.string = string
    self.int = int
    self.double = double
    self.decimal = decimal
    self.bool = bool
    self.date = date
    self.data = data
    self.uuid = uuid
    self.strings = strings
    self.ints = ints
    self.optionalStrings = optionalStrings
    self.optionalInts = optionalInts
  }
}

enum をプロパティに定義する

enum は Codable に準拠することによって定義可能になります。 オプショナルの enum も定義可能です。

@Model
final class EnumItem {
  
  var plain: Enum
  var plains: [Enum]
  var string: EnumString
  var strings: [EnumString]
  var int: EnumInt
  var ints: [EnumInt]
  var associatedValue: EnumAssociatedValue
  var associatedValues: [EnumAssociatedValue]
  var generic: EnumGeneric<String, Int>
  var generics: [EnumGeneric<String, Int>]
  
  var optionalPlain: Enum?

  init(plain: Enum, plains: [Enum], string: EnumString, strings: [EnumString], int: EnumInt, ints: [EnumInt], associatedValue: EnumAssociatedValue, associatedValues: [EnumAssociatedValue], generic: EnumGeneric<String, Int>, generics: [EnumGeneric<String, Int>], optionalPlain: Enum?) {
    self.plain = plain
    self.plains = plains
    self.string = string
    self.strings = strings
    self.int = int
    self.ints = ints
    self.associatedValue = associatedValue
    self.associatedValues = associatedValues
    self.generic = generic
    self.generics = generics
    self.optionalPlain = optionalPlain
  }
}

enum Enum: Codable {
  
  case foo
  case bar
}

enum EnumString: String, Codable {
  
  case foo
  case bar
}

enum EnumInt: Int, Codable {
  
  case foo
  case bar
}

enum EnumAssociatedValue: Codable, Equatable {
  
  case foo(String)
  case bar(String, Int)
  case baz(string: String, int: Int)
  case qux(String?)
}

enum EnumGeneric<T1: Codable & Equatable, T2: Codable & Equatable>: Codable, Equatable {
  
  case foo(T1)
  case bar(T2)
}

struct をプロパティに定義する

Codable に準拠することによって struct を定義できます。ただし、 class は Codable に準拠しても定義不可でランタイムエラーが発生します。

String, Int などの基本的な型と Codable に準拠した enum や struct を定義できます。

@Model
final class CodableStructItem {
  
  var child: ChildCodableStructItem
  
  init(child: ChildCodableStructItem) {
    self.child = child
  }
}
  
struct ChildCodableStructItem: Codable {
  
  var string: String
  var int: Int
  var double: Double
  var decimal: Decimal
  var bool: Bool
  var date: Date
  var data: Data
  var uuid: UUID
  
  var plainEnum: Enum
  var grandchild: GrandchildCodableStructItem
}

struct GrandchildCodableStructItem: Codable {
  
  var value: Int
}

⚠️ いくつか struct 内に定義できない型があります

定義できないオプショナル型がある

Optional<Int> は可能だったのですが、 Optional<String> は保存不可という挙動で fetch 時に次のログが出力されました。すべては把握できていないのですが、実際に定義するときは十分に検証する必要があります。

CoreData: error: Row (pk = 1) for entity 'InvalidCodableStructItemHasOptional' is missing mandatory text data for property 'optionalString'

@Model
final class InvalidCodableStructItemHasOptionalString {
  
  var child: ChildInvalidCodableStructItemHasOptionalString
  
  init(child: ChildInvalidCodableStructItemHasOptionalString) {
    self.child = child
  }
}

struct ChildInvalidCodableStructItemHasOptionalString: Codable {
  
  var optionalValue: String? // この定義によってモデルの保存が失敗する…
}

配列は定義できない

Array<Int>Array<String> を定義すると insert 時点では問題ないような挙動なのですが、実際にプロパティにアクセスすると次のランタイムエラーが発生します。

Could not cast value of type '_NSInlineData' (0x1028736c0) to 'NSArray' (0x10382f578).

リレーションを定義する

データベースのリレーションとは、データベース内のテーブル間の関連性のことを指します。主なリレーションの種類は次のとおりです。

関連 説明
1 : 1
(1対1)
あるテーブルのレコードが、別のテーブルのレコードと1つだけ関連する場合
1 : N
(1対多)
あるテーブルの1つのレコードが、別のテーブルの複数のレコードと関連する場合
N : N
(多対多)
あるテーブルの複数のレコードが、別のテーブルの複数のレコードと関連する場合

リレーションは、データの整合性を保つためや、効率的なクエリを実行するために設定されます。

SwiftData でリレーションを定義すると、関連があるプロパティに自動的に値が入ります。また後述の Delete Rule を定義することで削除時にリレーションがあるモデルを nil にしたり、DB から削除する振る舞いも定義可能です。

Explicit relationship(明示的なリレーション)と Inferred relationship(推測されたリレーション)について

SwiftData のリレーションには Explicit relationship(明示的なリレーション)と Inferred relationship(推測されたリレーション)があります。

Explicit relationship は @Relationship マクロを使用する定義方法で、@Relationship マクロを使用しなくても Inferred relationship(推測されたリレーション)が定義されるケースもあります。

1 : 1 のリレーションを定義する

Explicit relationship(明示的なリレーション)

@Relationship(inverse:) を使用してリレーションを定義します。inverse でリレーション先のプロパティの KeyPath を定義することで明示的にリレーションが定義されます。

@Model
final class ExplicitOneToOneItem {
  
  @Attribute(.unique) 
  var id: Int
  
  @Relationship(inverse: \ChildExplicitOneToOneItem.parent) 
  var child: ChildExplicitOneToOneItem?
  
  init(id: Int, child: ChildExplicitOneToOneItem?) {
    self.id = id
    self.child = child
  }
}
  
@Model
final class ChildExplicitOneToOneItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: ExplicitOneToOneItem?
  
  init(id: Int) {
    self.id = id
  }
}

次のようにリレーションがあるモデルを追加、削除することで自動的にリレーションを定義したプロパティに値がセットされたり nil になります。この nil になる挙動は正確には後述の DeleteRule の影響で、デフォルト値が DeleteRule.nullify となっているためです。

リレーションで注意が必要なのは、保存前にリレーションが定義されているプロパティにアクセスするとランタイムエラーが発生します。

let context = try ModelContext(for: ExplicitOneToOneItem.self)

let child = ChildExplicitOneToOneItem(id: 10)
let parent = ExplicitOneToOneItem(id: 1, child: child)

// - Note: 保存前はアクセスできず、ランタイムエラーが発生
// Thread 1: EXC_BREAKPOINT
// XCTAssertNil(child.parent)
    
context.insert(parent)
try context.save()

XCTAssertEqual(try context.fetchCount(for: ExplicitOneToOneItem.self), 1)
XCTAssertEqual(try context.fetchCount(for: ChildExplicitOneToOneItem.self), 1)

// 保存後は child.parent に自動的に parent がセットされている
XCTAssertEqual(child.parent?.persistentModelID, parent.persistentModelID)
XCTAssertEqual(child.parent?.id, parent.id)

// fetch した値にも parent がセットされている
XCTAssertEqual(
  try context.fetch(for: ChildExplicitOneToOneItem.self).first?.parent?.persistentModelID,
  parent.persistentModelID
)
XCTAssertEqual(
  try context.fetch(for: ChildExplicitOneToOneItem.self).first?.parent?.id,
  parent.id
)

// parent を削除する
context.delete(parent)
try context.save()

// parent は削除されている
XCTAssertEqual(try context.fetchCount(for: ExplicitOneToOneItem.self), 0)
// ただし、 child は削除されていない!これを自動的に削除するには Delete Rule の定義が必要
XCTAssertEqual(try context.fetchCount(for: ChildExplicitOneToOneItem.self), 1)

// 自動的に child.parent が nil になる
XCTAssertNil(child.parent)
// fetch した child.parent も nil になっている
XCTAssertNil(
  try XCTUnwrap(context.fetch(for: ChildExplicitOneToOneItem.self).first).parent
)

Inferred relationship(推測されたリレーション)

次の定義方法だと、 var child@Relationship(inverse:) を定義しなくても child と parent のリレーションが推測されて、前述の Explicit relationship と同じ挙動になります。ただし、この挙動は暗黙的すぎて把握が難しいため正直微妙な仕様だと感じています。

@Model
final class InferredOneToOneItem {
  
  @Attribute(.unique)
  var id: Int
  
  var child: ChildInferredOneToOneItem?
  
  init(id: Int, child: ChildInferredOneToOneItem?) {
    self.id = id
    self.child = child
  }
}

@Model
final class ChildInferredOneToOneItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: InferredOneToOneItem?
  
  init(id: Int) {
    self.id = id
  }
}

1 : N のリレーションを定義する

Explicit relationship(明示的なリレーション)

@Model
final class ExplicitOneToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(inverse: \ChildExplicitOneToManyItem.parent)
  var children: [ChildExplicitOneToManyItem] = []
  
  init(id: Int, children: [ChildExplicitOneToManyItem] = []) {
    self.id = id
    self.children = children
  }
}

@Model
final class ChildExplicitOneToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: ExplicitOneToManyItem?
  
  init(id: Int) {
    self.id = id
  }
}

1 : 1 と同様にリレーションがあるモデルを追加、削除することで自動的にリレーションを定義したプロパティに値がセットされたり nil になります。

let context = try ModelContext(for: ExplicitOneToManyItem.self)

let child1 = ChildExplicitOneToManyItem(id: 1)
let child2 = ChildExplicitOneToManyItem(id: 2)
let parent = ExplicitOneToManyItem(id: 1, children: [child1, child2])

// - Note: 保存前はアクセスできず、ランタイムエラーが発生
// Thread 1: EXC_BREAKPOINT
// XCTAssertNil(child1.parent)

context.insert(parent)
try context.save()

XCTAssertEqual(try context.fetchCount(for: ExplicitOneToManyItem.self), 1)
XCTAssertEqual(try context.fetchCount(for: ChildExplicitOneToManyItem.self), 2)

// リレーションが定義されているので自動的に parent がセットされている
XCTAssertEqual(child1.parent?.persistentModelID, parent.persistentModelID)
XCTAssertEqual(child1.parent?.id, parent.id)
XCTAssertEqual(child2.parent?.persistentModelID, parent.persistentModelID)
XCTAssertEqual(child2.parent?.id, parent.id)
try context.fetch(for: ChildExplicitOneToManyItem.self).forEach {
  XCTAssertEqual($0.parent?.persistentModelID, parent.persistentModelID)
  XCTAssertEqual($0.parent?.id, parent.id)
}

// child も自動的にセットされている
XCTAssertEqual(
  Set(parent.children.map { $0.id }),
  Set([child1, child2].map { $0.id })
)
XCTAssertEqual(
  try Set(context.fetch(for: ChildExplicitOneToManyItem.self).map { $0.id }),
  Set([child1, child2].map { $0.id })
)

// ただし、この追加方法では persistentModelID は不一致になる
XCTAssertNotEqual(
  Set(parent.children.map { $0.persistentModelID }),
  Set([child1, child2].map { $0.persistentModelID })
)
XCTAssertNotEqual(
  try Set(context.fetch(for: ChildExplicitOneToManyItem.self).map { $0.persistentModelID }),
  Set([child1, child2].map { $0.persistentModelID })
)

// parent を削除
context.delete(parent)
try context.save()

// 削除されていることを確認
XCTAssertEqual(try context.fetchCount(for: ExplicitOneToManyItem.self), 0)
XCTAssertEqual(try context.fetchCount(for: ChildExplicitOneToManyItem.self), 2)

// リレーションが設定されているので自動的に nil になる
XCTAssertNil(child1.parent)
XCTAssertNil(child2.parent)
try context.fetch(for: ChildExplicitOneToManyItem.self).forEach {
  XCTAssertNil($0.parent)
}

Inferred relationship(推測されたリレーション)

1 : N のリレーションも推測されて定義されます。

@Model
final class InferredOneToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var children: [ChildInferredOneToManyItem] = []
  
  init(id: Int, children: [ChildInferredOneToManyItem] = []) {
    self.id = id
    self.children = children
  }
}

@Model
final class ChildInferredOneToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: InferredOneToManyItem?
  
  init(id: Int) {
    self.id = id
  }
}

N : N のリレーションを定義する

Explicit relationship(明示的なリレーション)

@Model
final class ExplicitManyToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(inverse: \ChildExplicitManyToManyItem.parents)
  var children: [ChildExplicitManyToManyItem] = []
  
  init(id: Int, children: [ChildExplicitManyToManyItem] = []) {
    self.id = id
    self.children = children
  }
}

@Model
final class ChildExplicitManyToManyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parents: [ExplicitManyToManyItem] = []
  
  init(id: Int, parents: [ExplicitManyToManyItem] = []) {
    self.id = id
    self.parents = parents
  }
}

リレーションの追加方法

N : N のリレーションはいくつかの追加方法があり、ランタイムエラーが発生する可能性もあるため注意が必要です。

1. 初期化後にリレーションを追加する

この方法が一番ランタイムエラーを防げる方法だと思われます。

let context = try ModelContext(for: ExplicitManyToManyItem.self)

let parent1 = ExplicitManyToManyItem(id: 1)
let parent2 = ExplicitManyToManyItem(id: 2)
let child = ChildExplicitManyToManyItem(id: 1)

// 先にモデルを追加する
context.insert(parent1)
context.insert(parent2)
context.insert(child)
try context.save()

// parent に child を追加
parent1.children.append(child)
try context.save()

2. 初期化時にリレーションを追加する

こちらの方法は追加方法によってはランタイムエラーが発生してしまうため十分に注意が必要です。

let context = try ModelContext(for: ExplicitManyToManyItem.self)

// 初期化時にリレーションを追加する
let child = ChildExplicitManyToManyItem(id: 1)
let parent1 = ExplicitManyToManyItem(id: 1, children: [child])
      
context.insert(parent1)
try context.save()

// parent2 の初期化時に すでに modelContext に紐づいている child を追加することはできないため
// parent2 を単体で insert する必要がある
let parent2 = ExplicitManyToManyItem(id: 2)
context.insert(parent2)
try context.save()

// child に parent のリレーションを追加
child.parents.append(parent2)
try context.save()

Inferred relationship(推測されたリレーション)

色々検証しましたが、おそらく推測されて定義されないはずです。

属性の設定 / @Attribute マクロ

一意制約 / Unique 制約

プロパティに @Attribute(.unique) マクロを定義すると、そのプロパティは一意制約がつきます。一意制約とはそのモデルにおいて同じ値が存在しないようにする制約です。

@Model
final class UniqueItem {
  
  @Attribute(.unique) var id: Int
  var value: String
  
  init(id: Int, value: String) {
    self.id = id
    self.value = value
  }
}

リレーション間の削除ルールの設定 / DeleteRule

@Relationship(deleteRule:) でリレーション間での削除ルールを設定できます。

DeleteRule.nullify / nil にする

リレーション元のモデルが削除されたらリレーション先(inverse に指定したプロパティ)が nil になります。こちらがデフォルト値です。

@Model
final class DeleteRuleNoActionItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(deleteRule: .noAction, inverse: \ChildDeleteRuleNoActionItem.parent)
  var child: ChildDeleteRuleNoActionItem?
  
  init(id: Int, child: ChildDeleteRuleNoActionItem?) {
    self.id = id
    self.child = child
  }
}
  
@Model
final class ChildDeleteRuleNoActionItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: DeleteRuleNoActionItem?
  
  init(id: Int) {
    self.id = id
  }
}
typealias Parent = DeleteRuleNullifyItem
typealias Child = ChildDeleteRuleNullifyItem

let context = try ModelContext(for: Parent.self)

let child = Child(id: 10)
let parent = Parent(id: 1, child: child)

context.insert(parent)
try context.save()

XCTAssertNotNil(parent.child)
XCTAssertNotNil(child.parent)

context.delete(parent)
try context.save()

// `DeleteRule.nullify` によって自動的に nil になる
XCTAssertNil(child.parent)

DeleteRule.cascade / 親が削除されたら子も削除する

カスケード削除(Cascade Delete / Cascading Delete)は、データベースのリレーショナルモデルにおける機能で、親テーブルのレコードが削除されたとき、それに関連付けられた子テーブルのレコードも自動的に削除される仕組みです。これにより、データの整合性が保たれ、関連データの不整合を防ぐことができます。

SwiftData にもカスケード削除がサポートされており、 @Relationship(deleteRule: .cascade) で定義可能です。次は 1 : 1 のリレーションでカスケード削除を定義する例です。

@Model
final class ParentItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(deleteRule: .cascade, inverse: \ ChildItem.parent)
  var child: ChildItem?
  
  init(id: Int, child: ChildItem? = nil) {
    self.id = id
    self.child = child
  }
}

@Model
final class ChildItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: ParentItem?
  
  init(id: Int, parent: ParentItem? = nil) {
    self.id = id
    self.parent = parent
  }
}

ただし、SwiftData におけるカスケード削除は思ったよりも挙動が複雑で、モデルの初期化と insert 方法に注意する必要があります。筆者も完全には把握できていないのですが、検証の結果、次の点に注意する必要があります。検証コードは長いためこちらでは割愛させていただきます。

1 : 1 のリレーション

  • 初期化時にリレーションを追加する。
  • 子を insert する(親を insert しない)。
  • 親を削除すると子がカスケード削除される。

TODO: URL & QRコード

1 : N のリレーション

  • 新しい ModelContext から取得したモデルを使って削除するとカスケード削除される。

TODO: URL & QRコード

N : N のリレーション

  • 特別気にすることなくモデルを削除するとカスケード削除される。

TODO: URL & QRコード

DeleteRule.deny / リレーション先にモデルがあれば削除できない

1 つ以上のリレーションが含まれている場合にリレーションを削除できないようにする。

@Model
final class DeleteRuleDenyItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(deleteRule: .deny, inverse: \ChildDeleteRuleDenyItem.parent)
  var children: [ChildDeleteRuleDenyItem] = []
  
  init(id: Int, children: [ChildDeleteRuleDenyItem] = []) {
    self.id = id
    self.children = children
  }
}

@Model
final class ChildDeleteRuleDenyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: DeleteRuleDenyItem?
  
  init(id: Int) {
    self.id = id
  }
}
typealias Parent = DeleteRuleDenyItem
typealias Child = ChildDeleteRuleDenyItem

let context = try ModelContext(for: Parent.self)

let child = Child(id: 10)
let parent = Parent(id: 1, children: [child])

context.insert(parent)
try context.save()

XCTAssertFalse(parent.children.isEmpty)
XCTAssertNotNil(child.parent)

context.delete(parent)

/// `parent.children` に値があるため削除できずエラーがスローされる
///
/// `NSLocalizedDescription=Items cannot be deleted from %{PROPERTY}@.`
XCTAssertThrowsError(
  try context.save()
)

context.delete(child)
try context.save()

XCTAssertEqual(try context.fetchCount(for: Child.self), 0)

/// child を削除したので parent も削除可能になった
context.delete(parent)
try context.save()

XCTAssertEqual(try context.fetchCount(for: Parent.self), 0)

DeleteRule.noAction / 何もしない

リレーション元を削除してもリレーション先(inverse に指定したプロパティ)のプロパティを変更せず、削除されたモデルへの参照を残す設定です。

ただし、 1 : 1 のリレーションではうまくいかず、明示的にリレーション先のプロパティを nil にしないとランタイムエラーが発生してしまいました。

@Model
final class DeleteRuleNullifyItem {
  
  @Attribute(.unique)
  var id: Int
  
  @Relationship(deleteRule: .nullify, inverse: \ChildDeleteRuleNullifyItem.parent)
  var child: ChildDeleteRuleNullifyItem?
  
  init(id: Int, child: ChildDeleteRuleNullifyItem?) {
    self.id = id
    self.child = child
  }
}
  
@Model
final class ChildDeleteRuleNullifyItem {
  
  @Attribute(.unique)
  var id: Int
  
  var parent: DeleteRuleNullifyItem?
  
  init(id: Int) {
    self.id = id
  }
}
typealias Parent = DeleteRuleNoActionItem
typealias Child = ChildDeleteRuleNoActionItem

let context = try ModelContext(for: Parent.self)

let child = Child(id: 10)
let parent = Parent(id: 1, child: child)

context.insert(parent)
try context.save()

XCTAssertNotNil(parent.child)
XCTAssertNotNil(child.parent)

#if true
/// - Note: parent を削除する前に明示的に nil にしないとランタイムエラーで `EXC_BAD_ACCESS` が発生してしまう。
child.parent = nil
#endif
context.delete(parent)

try context.save()

// NoAction なので NotNil になるのかもしれませんが、ランタイムエラーが発生するため検証できず
XCTAssertNil(child.parent)

プロパティを永続化から除外する / @Transient マクロ

プロパティに @Transient マクロを使用することで、永続化から除外するプロパティにできます。

注意点としては、同一 context においてはメモリ上でキャッシュされているため値は保持されています。異なる context(つまりはファイルストレージの内容)では永続化されておらず fetch するとデフォルト値になっています。そのため @Transient マクロをつけたプロパティはデフォルト値が必須となります。

@Model
final class TransientItem {
  
  var value: Int
  
  @Transient
  var ignoreValue: Int = 0 // デフォルト値が必須
  
  init(value: Int) {
    self.value = value
  }
}
let context = try ModelContext(for: TransientItem.self, storageType: .file)
    
let item = TransientItem(value: 1)
let defaultValue = item.ignoreValue
let updatedValue = 100
XCTAssertNotEqual(defaultValue, updatedValue)

item.ignoreValue = updatedValue
context.insert(item)
try context.save()

XCTAssertEqual(try context.fetchCount(for: TransientItem.self), 1)

// 保存後も更新した値になっていることを確認
XCTAssertEqual(item.ignoreValue, updatedValue)

// 同一 context から fetch したモデルは updatedValue になっていることに注意
XCTAssertEqual(
  try context.fetch(for: TransientItem.self).first?.ignoreValue,
  updatedValue
)

// 異なる context から fetch したモデルは defaultValue になっている
XCTAssertEqual(
  try ModelContext(
    for: TransientItem.self,
    storageType: .file,
    shouldDeleteOldFile: false
  )
  .fetch(for: TransientItem.self).first?.ignoreValue,
  defaultValue
)

つまずきやすい仕様

persistentModelID は主キーとして使用しない方がよさそう

PersistentModel は var persistentModelID: PersistentIdentifier { get } という SwiftData モデルを一意に識別する ID を持っています。データ構造は次のとおりです。

PersistentIdentifier(
  id: SwiftData.PersistentIdentifier.ID(
    url: x-coredata:///Child/tF7B18E54-5BB2-4DEC-B96A-B2C1CD0BDBA26
  ),
  implementation: SwiftData.PersistentIdentifierImplementation
)

persistentModelID は default extension で定義されており、一意になるように値が採番されるため、主キー(Primary Key)のように使えると思ったのですが、次の理由からそのような用途では使用しない方がよさそうです。

1. 異なる ModelContext から取得した場合に、取得元が同一のレコードであってもモデルの persistentModelID は一致しない

PersistentIdentifier は Hashable に準拠しています。異なる context から取得した同一モデルの item.persistentModelID.hashValue は一致します。ただし、 == では一致しません。そのため、persistentModelID は同一 context において一意の ID といえそうです。

final class PropertyTests: XCTestCase {

  func testPersistentID() throws {
    let context = try ModelContext(for: SimpleItem.self, storageType: .file)

    let item = SimpleItem(value: 1)
    context.insert(item)
    try context.save()

    let otherItem = try XCTUnwrap(
      ModelContext(for: SimpleItem.self, storageType: .file, shouldDeleteOldFile: false)
        .fetch(for: SimpleItem.self)
        .first
    )

    // 一致する
    // e.g. `50ECC9B3-E716-4605-8F0A-9F34F171627C`
    XCTAssertEqual(
      item.persistentModelID.storeIdentifier,
      otherItem.persistentModelID.storeIdentifier
    )

    // 一致する
    // e.g. `ID(url: x-coredata://098062A1-E83A-41E0-9CA6-3898D6837347/SimpleItem/p1)`
    XCTAssertEqual(
      item.persistentModelID.id,
      otherItem.persistentModelID.id
    )

    // 一致する
    // e.g. `SimpleItem`
    XCTAssertEqual(
      item.persistentModelID.entityName,
      otherItem.persistentModelID.entityName
    )

    // 一致する
    XCTAssertEqual(
      item.persistentModelID.hashValue,
      otherItem.persistentModelID.hashValue
    )

    // ただし、 `persistentModelID.hashValue` が同一だが、`persistentModelID` 自体の Equatable は一致しない
    XCTAssertNotEqual(
      item.persistentModelID,
      otherItem.persistentModelID
    )

    let itemID = item.persistentModelID
    let otherID = otherItem.persistentModelID

    XCTAssertNotNil(
      try context.fetch(
        FetchDescriptor<SimpleItem>(
          predicate: #Predicate {
            $0.persistentModelID == itemID
          }
        )
      )
      .first
    )
    // Equatable で同値判定されないので、異なる context に対しては persistentModelID は意味がない
    XCTAssertNil(
      try context.fetch(
        FetchDescriptor<SimpleItem>(
          predicate: #Predicate {
            $0.persistentModelID == otherID
          }
        )
      )
      .first
    )
  }
}

2. リレーションがあるモデルを追加した場合には、追加方法によってリレーション元とリレーション先の persistentModelID が異なる

前述のようにリレーションがないモデルについては素直に一意の ID として使用可能なのですが、リレーションがある場合には注意が必要あります。仕様も直感的ではないため fetch 条件として使うことも控えた方がよさそうです。

typealias ParentItem = ExplicitOneToOneItem
typealias ChildItem = ChildExplicitOneToOneItem

let context = try ModelContext(for: ParentItem.self)

let child = ChildItem(id: 1)
let parent = ParentItem(id: 1, child: child)

context.insert(parent)
try context.save()

XCTAssertEqual(try context.fetchCount(for: ParentItem.self), 1)
XCTAssertEqual(try context.fetchCount(for: ChildItem.self), 1)


// parent.child.id と child.id が同一なことを確認
XCTAssertEqual(
  parent.child?.id,
  child.id
)
// child.id が fetch したモデルと同一なことを確認
XCTAssertEqual(
  child.id,
  try context.fetch(for: ChildItem.self).first?.id
)

// parent.persistentModelID は fetch した parent.persistentModelID は同一
XCTAssertEqual(
  parent.persistentModelID,
  try context.fetch(for: ParentItem.self).first?.persistentModelID
)
// ⚠️ child.persistentModelID と fetch した child.persistentModelID は異なる
XCTAssertNotEqual(
  // PersistentIdentifier(
  //   id: SwiftData.PersistentIdentifier.ID(
  //     url: x-coredata:///Child/tF7B18E54-5BB2-4DEC-B96A-B2C1CD0BDBA26
  //   ),
  //   implementation: SwiftData.PersistentIdentifierImplementation
  // )
  child.persistentModelID,
  // PersistentIdentifier(
  //   id: SwiftData.PersistentIdentifier.ID(
  //     url: x-coredata://97B640B8-8368-409C-ABFA-9C10CDDD9B5A/Child/p1
  //   ),
  //   implementation: SwiftData.PersistentIdentifierImplementation
  // )
  try context.fetch(for: ChildItem.self).first?.persistentModelID
)

// ⚠️ parent.child.persistentModelID と child.persistentModelID は異なる
XCTAssertNotEqual(
  // SwiftData.PersistentIdentifier(
  //   id: SwiftData.PersistentIdentifier.ID(
  //     url: x-coredata://347B798F-07F4-41E0-973F-B81487FC2B24/Child/p1
  //   ),
  //   implementation: SwiftData.PersistentIdentifierImplementation
  // )
  parent.child?.persistentModelID,
  // SwiftData.PersistentIdentifier(
  //   id: SwiftData.PersistentIdentifier.ID(
  //     url: x-coredata:///Child/t2BF40D07-0D7B-46F1-ACD5-AA4F3A845DD52
  //   ),
  //   implementation: SwiftData.PersistentIdentifierImplementation
  // )
  child.persistentModelID
)

この仕様については Cascade Delete がされる条件にも関係してそうなため調査を進めたのですが、難解で仕様を把握できませんでした。 PropertyTests.swift で試行錯誤しています。

@Model を定義したモデルは ModelContainer に schema を登録しないと初期化時にランタイムエラーが発生する

使用したいモデル含めた ModelContainer を初期化する前にモデルを初期化すると次のランタイムエラーが発生します。

Thread 1: Fatal error: failed to find a currently active container for SimpleItem

そのため @Model を定義したモデルは SwiftData での使用を前提となります。

let shcema = Schema([SimpleItem.self])
let modelConfiguration = ModelConfiguration(
  for: SimpleItem.self
)
// _ = SimpleItem(value: 1) // この時点で実行したらランタイムエラー
let modelContainer = try ModelContainer(
  for: SimpleItem.self,
  configurations: modelConfiguration
)

_ = SimpleItem(value: 1) // OK

サポートしていない型

網羅はできていないのですが、少なくとも次の型はサポートしていませんでした。

UInt

@Model
final class UnsupportedPropertyItemHasUInt {
  
  var uint: UInt
  
  init(uint: UInt) {
    self.uint = uint
  }
}

初期化はできるのですが、 Int.max 超える値を入れるとプロパティにアクセスするとランタイムエラーが発生します。おそらく SQLite が signed integer のみサポートしているからだと思われます。

typealias Item = UnsupportedPropertyItemHasUInt

let value = UInt(Int.max) // + 1
let context = try ModelContext(for: Item.self)
let item = Item(uint: value)

XCTAssertEqual(item.uint, value)

context.insert(item)
try context.save()
    
XCTAssertEqual(item.uint, value)

Any

プロパティに Any 型を定義してコンパイルすると @Model マクロで生成されたコード内に次のコンパイルエラーが発生します。

No exact matches in call to instance method 'getValue'

No exact matches in call to instance method 'setValue'

final class UnsupportedPropertyItemHasAny {
  
  var any: Any
  
  init(any: Any) {
    self.any = any
  }
}

#Predicate マクロに直接オブジェクトを渡すとコンパイルエラーが発生するケースがある

#Predicate マクロは KeyPath を生成してくれるのですが、その生成される内部表現の型が不一致になるとコンパイルエラーが発生します。

たとえ次のコードです。

let fetchDescriptor = FetchDescriptor<SimpleItem>(
  predicate: #Predicate {
    $0.persistentModelID == item.persistentModelID
  }
)

これは次のコンパイルエラーが発生します。

Cannot convert value of type 'PredicateExpressions.Equal<PredicateExpressions.KeyPath<PredicateExpressions.Variable<SimpleItem>, PersistentIdentifier>, PredicateExpressions.KeyPath<PredicateExpressions.Value<SimpleItem>, PersistentIdentifier>>' to closure result type 'any StandardPredicateExpression<Bool>'

コンパイルエラーの原因は、 #Predicate マクロが KeyPath を生成するときに、次のような異なる型同士を比較するコードが生成されてしまうためです。

  • PredicateExpressions.Variable<SimpleItem>
  • PredicateExpressions.Value<SimpleItem>

これを防ぐには次のように一時変数を渡すようにします。

let persistentModelID = item.persistentModelID
    
var fetchDescriptor = FetchDescriptor<SimpleItem>(
  predicate: #Predicate {
    $0.persistentModelID == persistentModelID
  }
)

TODO: 追加調査候補

調べ次第追記します。

Cascade Delete の挙動は納得できない部分があるため再調査

マイグレーション

@Model を Codable に準拠させる

複数の ModelConfiguration 定義する-

Relationship のプロパティ

  • minimumModelCount
  • maximumModelCount
  • originalName
  • hashModifier

ModelContext.delete(model:where:includeSubclasses:) の includeSubclasses の使い方

sql の発行タイミング

株式会社ゆめみ

Discussion