iOS・AndroidSwift

SwiftDataのスキーマ間に関係性を付与する

iOS・Android
この記事は約15分で読めます。

伝えたいこと

  • SwiftDataを用いてスキーマを定義する際のイメージ:

オブジェクト間の関係はオブジェクト指向の方法で表現する。

SwiftDataとは何か

  • SwiftDataはiOS17から使用できる、データを永続化するためのフレームワーク
  • CoreDataをラップしており、互換性がある
SwiftData - Xcode - Apple Developer
SwiftData makes it easy to persist data using declarative code. You can query and filter data using regular Swift code. And it’s designed to integrate seamlessl...

ルール

「関係メモ」は、コミュニティにおける人間関係を追跡できるアプリです。

ユーザーは特定のコミュニティに属することができ、メンバー間で様々な関係性を結びます。

  • パーソンは必ず一つ以上のコミュニティに所属する
  • コミュニティは複数のパーソンを持つ
  • パーソンは、所属するコミュニティのメンバーと関係性を結ぶ。

ER図で表現すると、以下のような構造になります。
パーソンとコミュニティは多対多の関係であり、
パーソンと関係性は一対多の関係です。

スキーマを定義する

ひとまず、それぞれのテーブルに必要なプロパティを整理しましょう。

SwiftDataのスキーマを作成する手順は非常に簡単です。

classに`@Model`修飾子をつけるだけです。

@Model
final classs `スキーマ名` {kiTk1CeE
}

パーソン、コミュニティ、関係 それぞれに必要なプロパティを整理します。

パーソン

一番重要なパーソンスキーマを作成します。

パーソンは名前・年齢・性別・所属コミュニティ・関係性 を保持します。

所属コミュニティ・関係性は別のテーブルと関連性を持たせたいと考えています。

蛇足ですが、@Modelを付与したモデル内でenumを使いたいときは、Codableに準拠させないとエラーが発生します。

@Model
final class Person {
    @Attribute let personID: UUID
    let name: String
    var age: Int
    let gender: Gender
    var community: [Community]
    var relationships: [CommunityRelationship]
    
    enum Gender: String, Codable  {
        case male = "男"
        case female = "女"
        case other = "その他"
    }
}

コミュニティ

コミュニティは、コミュニティの名前とメンバーを保持します。

@Attributeマクロを付与することで、一意制約をつけることができます。

一意制約を付与されたプロパティは、Communityオブジェクトが新規に作成される都度既存のデータと衝突していないか判定が実施されます。

仮に同じcommunityIDがすでに存在した場合、既存のデータのプロパティが更新されます。新しい値は作成されません。

memberには先ほど定義したPersonが配列で入ります。

Community <=> Person間の参照関係を持たせたいですね。

@Model
final class Community {
    @Attribute let communityID: UUID
    let communityName: String
    var member: [Person]
}

関係性

関係性は、当事者とその相手、および関係性のタイプを保持します。

@Model
final class CommunityRelationship {
    @Attribute let RelationshipID: UUID
    let Person1: Person
    let Person2: Person
    var relationshipType: RelationshipType
    
    enum RelationshipType: String, Codable  {
        case friend = "友人"
        case lover = "恋人"
        case spouse = "配偶者"
    }
}

Person1, Person2には先ほど定義したPersonが配列で入ります。

CommunityRelationship <=> Person間の参照関係を相互に持たせたいですね。

参照関係を作ってみる

ざっくりと必要なプロパティの整理が完了しました。

それぞれのテーブルの間の関係性を表現しましょう。

Personオブジェクトの以下の部分を、対応するオブジェクトと関係付けたいです。

communityはCommunity

relationshipsはCommunityRelationship

こうしたとき、どのような手法が選択できるでしょうか。

var community: [Community]
var relationships: [CommunityRelationship]

データベースで参照関係を作成する手法といえば、外部キー(Foreign Key)がパッと思い浮かびます。

一般的に、外部キーを設定するときは、他のテーブルのプライマリーキーを設定します。人間テーブルから、その人が所属するコミュニティテーブルを参照したいときは、人間テーブルに登録されたコミュニティテーブルのプライマリーキーをもとに、検索を行います。

しかし、SwiftDataにおいては、エンティティ間の関連を定義することで、あるオブジェクト(人間オブジェクト)が別のオブジェクト(コミュニティオブジェクト)を直接参照するように設計できます。

@Relationship

2つのモデル間の関係を管理する際は、@Relationshipを使用します。

@attached(peer)
macro Relationship(
    _ options: Schema.Relationship.Option...,
    deleteRule: Schema.Relationship.DeleteRule = .nullify,
    minimumModelCount: Int? = 0,
    maximumModelCount: Int? = 0,
    originalName: String? = nil,
    inverse: AnyKeyPath? = nil,
    hashModifier: String? = nil
)

deleteRuleは関連するエンティティが削除されたときの振る舞いを定義します。デフォルトは.nullifyで、これは関連するエンティティが削除された場合に関係をnullに設定することを意味します。他のオプションには、関連エンティティも一緒に削除する.cascadeや、削除を許可しないルールがあります。

minimumModelCount、maximumModelCountを指定することで、to-manyリレーションシップの最小数と 最大数を指定します。

inverseを通じ、関連するモデルが互いに参照できるようにすることができます。

Personオブジェクトから、CommunityとCommunityRelationshipオブジェクトへの参照を作成します。

    @Relationship(deleteRule: .noAction, inverse: \Community.member)
    var community: [Community]
    @Relationship(deleteRule: .cascade, inverse: \CommunityRelationship.Person1ID)
    var relationships: [CommunityRelationship]

ある人物が削除されたとしても、Communityは削除されません。

一方で、関係性は人物の消滅と連動し、削除したいので、cascadeを設定します。これにより、人物の削除と関係性の削除は連動します。

人物に@Relationshipを付与したので、Community・CommunityRelationship側からも関係性の定義を行う必要があるようにも思えるかもしれませんが、必要ありません。

指定すると、「Fatal error: Inverse Relationship does not exist」エラーが発生します。

inverseは日本語に訳すと「逆関係」という意味ですが、SwiftDataにおいて@Relationshipは一方から指定されれば十分です。

今回の例で取り上げるPersonモデルとCommunityモデルについて説明します。

各人物が多数のコミュニティに属している場合、Personモデルのプロパティを宣言する際に、Communityモデルとの関係を定義します。

Personモデルを初期化した後、そのPersonモデルに関連するCommunityモデルへの参照を渡します。これにより、一貫性のあるデータ構造が作成されます。このプロセスを通じて、PersonとCommunityの間の関係性が明確に定義され、データ間のナビゲーションが容易になります。

Personオブジェクトから、関係するCommunityとCommunityRelationshipオブジェクトを参照する準備が整いました。

混乱1: PKはどこに設定するの?

関係データベースの定石として、一意性を持たせたPKをプロパティに保持させる。という発想があると思います。

しかし、SwiftDataにおいてはそうした発想は不要です。

PKは関係データベースにおいて他のテーブルとの関係を構築する際の基点として使用されますが、そもそもSwiftDataは関係を@Relationshipを介して直にオブジェクトを参照することで定義します。

そもそもPKという発想が馴染まないような感触を受けました。

混乱2:FKはどう設定するの?

外部キーは、あるテーブルのフィールドが別のテーブルのキー(主にプライマリーキー)に依存していることを示し、これによりテーブル間でのデータの整合性と関連付けを保持するものです。

外部キー制約を使用することで、リレーショナルデータベース管理システム(RDBMS)は参照整合性を維持し、関連するテーブル間でデータが適切にリンクされていることを保証します。

私の理解が誤っている可能性がありますが、通常外部キーは検索される可能性があるテーブル間でたくさん設定されるものというイメージがあります。

データの一貫性と整合性を維持するために参照整合性、削除の制約は@Relationshipを介し、担保され、実現します。

SwiftData(CoreData)は関係データベースではない

Core Data is often considered as a database. Let me clear this for you “it’s not”. It is the “M” in your MVC(Model View Controller). It is a framework that you use to manage the object graph and persist that object graph.Core Data is not a relational database. It is actually a framework that lets developers store (or retrieve) data from a database in an object-oriented way. With Core Data, you can easily map the objects in your apps to the table records in the database without even knowing any SQL.

Core Dataはしばしばデータベースとみなされますが、それは違います。それはあなたのMVC(Model View Controller)の「M」です。オブジェクトグラフを管理し、そのオブジェクトグラフを永続化するために使用するフレームワークです。Core Dataはリレーショナルデータベースではありません。実際には、開発者がオブジェクト指向の方法でデータベースからデータを保存(または取得)することを可能にするフレームワークです。Core Dataを使用すると、SQLを知らなくても、アプリのオブジェクトをデータベースのテーブルレコードに簡単にマッピングできます。

Core Data Overview
Core Data is an object graph and persistence framework provided by Apple in the macOS and iOS operating systems.

ModelContainerを準備する

SwiftDataを使うためには、最低でも1つのModelContainerを準備する必要があります。

ModelContainerには、スキーマとストレージオプションを設定します。

CRUD操作を行いたいときは、ModelContainerを経由しデータの操作を行います。

ModelContainerは他のModelContainerに干渉しません。

この操作は、細かいViewのレベルで行うことができるらしいです。

ModelContainerを作成しました。

スキーマとストレージオプションを指定し、データモデルの設定を行っています。ここでは、永続化ストアにデータを保存する設定を行っています。

    var sharedModelContainer: ModelContainer = {
        let schema = 
        Schema([
            Person.self,
            Community.self,
            CommunityRelationship.self
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        do {
            return try ModelContainer(
                for: schema,
                configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

アプリのウィンドウに ModelContainerを設定します。

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

モデルの変更はViewへ伝達される

@Modelをつけたモデルのプロパティの変更は、自動的にViewに伝達されます。

@Query

@Queryを使うことで、コンテキスト内で@Modelをつけたモデルの更新とViewの更新を連動させることができます。

@Queryを使用するためには、ViewにModelContainerを設定しなければなりません。

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var peaple: [Person]
    @Query private var community: [Community]
    @Query private var communityRelationship: [CommunityRelationship]
    @State private var selection: Person?

読み込み 更新 削除できるのは、先ほど作成した sharedModelContainerのみです。

初期データをセットする

初期データをセットします。

CommunityとCommunityRelationship、Personをインスタンス化します。

Community、CommunityRelationshipにPersonへの参照を持たせます。

extension Community {
    static let houkagoClub = Community(communityName: "放課後クラブ", member: [])
}

extension CommunityRelationship {
    static let hitomiAndTakeshi = CommunityRelationship(relationshipType: .friend)
}

extension Person {
    static var hitomi = Person(personID: UUID(), name: "ひとみ", age: 16, gender: .female, community: [], relationships: [])
    
    static var takeshi = Person(personID: UUID(), name: "たけし", age: 16, gender: .male, community: [], relationships: [])
    
    static func insertSampleData(modelContext: ModelContext) {
        modelContext.insert(hitomi)
        modelContext.insert(takeshi)
        Community.houkagoClub.member.append(hitomi)
        CommunityRelationship.hitomiAndTakeshi.person1 = hitomi
        CommunityRelationship.hitomiAndTakeshi.person2 = takeshi
    }
}

Instance Method`.task`を用いて、insertSampleDataを実行します。

これでアプリ内に関係性を保持したサンプルデータを作成することができました。

最後に

本稿ではSwiftDataを用いてデータ間の関係性を持たせる方法を提示いたしました。

SwiftDataにおける、モデル間の関係性の持たせ方の一例として参照いただけると嬉しいです。