SwiftDataのカスタムデータストアを試してみた

# SwiftDataのカスタムデータストアって？
WWDC2024で発表があったSwiftDataの新機能。
カスタムデータストアを使用することで、JSONファイル・Webサービス・データベースエンジンなどの永続化に対応できるとのこと。

https://developer.apple.com/jp/xcode/swiftdata/

WWDC2024のビデオではModelContextがストアをどのようなやり取りをしているのか図を用いて説明されているので、ビデオを見た方が分かりやすいと思います。

https://developer.apple.com/jp/videos/play/wwdc2024/10138/

## サンプルコード

WWDCのビデオでも紹介されている、JSONファイルをデータストアとしているサンプルコードです。

<details><summary>コード全文</summary>

```swift:Item.swift
import Foundation
import SwiftData

@Model
class Item {
    #Unique<Item>([\.id])
    
    var id: UUID
    var timestamp: Date
    
    init() {
        self.id = UUID()
        self.timestamp = Date()
    }
}
```
```swift:JSONDataStore.swift
import Foundation
import SwiftData

final class JSONStoreConfiguration: DataStoreConfiguration {
    typealias Store = JSONDataStore
    
    var name: String
    var schema: Schema?
    var fileURL: URL
    
    init(name: String, schema: Schema? = nil, fileURL: URL) {
        self.name = name
        self.schema = schema
        self.fileURL = fileURL
    }
    
    static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
        lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

final class JSONDataStore: DataStore {
    typealias Configuration = JSONStoreConfiguration
    typealias Snapshot = DefaultSnapshot
    
    var configuration: JSONStoreConfiguration
    var name: String
    var schema: Schema
    var identifier: String
    
    init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
        self.configuration = configuration
        self.name = configuration.name
        self.schema = configuration.schema!
        self.identifier = configuration.fileURL.lastPathComponent
    }
    
    func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
        if request.descriptor.predicate != nil {
            throw DataStoreError.preferInMemoryFilter
        } else if request.descriptor.sortBy.count > 0 {
            throw DataStoreError.preferInMemorySort
        }
        
        let objects = try self.read()
        let snapshot = objects.values.map { $0 }
        return .init(descriptor: request.descriptor, fetchedSnapshots: snapshot)
    }
    
    func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
        var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
        var serializedItems = try self.read()
        
        for snapshot in request.inserted {
            let permanentIdentifier = try PersistentIdentifier.identifier(
                for: identifier,
                entityName: snapshot.persistentIdentifier.entityName,
                primaryKey: UUID()
            )
            
            let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
            serializedItems[permanentIdentifier] = permanentSnapshot
            remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
        }
        
        for snapshot in request.updated {
            serializedItems[snapshot.persistentIdentifier] = snapshot
        }
        
        for snapshot in request.deleted {
            serializedItems[snapshot.persistentIdentifier] = nil
        }
        
        try self.write(serializedItems)
        
        return .init(for: self.identifier, remappedIdentifiers: remappedIdentifiers, snapshotsToReregister: serializedItems)
    }
    
    private func read() throws -> [PersistentIdentifier: DefaultSnapshot] {
        guard FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) else {
            return [:]
        }
        
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        
        let items = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
        var result = [PersistentIdentifier: DefaultSnapshot]()
        items.forEach {
            result[$0.persistentIdentifier] = $0
        }
        
        return result
    }
    
    private func write(_ items: [PersistentIdentifier: DefaultSnapshot]) throws {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let jsonData = try encoder.encode(items.values.map({ $0 }))
        try jsonData.write(to: configuration.fileURL)
    }
}
```
```swift:ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Item.timestamp) private var items: [Item]
    
    var body: some View {
        NavigationSplitView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        VStack {
                            Text("id: \(item.id)")
                            Text("timestamp: \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                        }
                    } label: {
                        Text(item.id.uuidString)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
    }
    
    private func addItem() {
        withAnimation {
            let newItem = Item()
            modelContext.insert(newItem)
            do {
                try modelContext.save()
            } catch {
                print(error)
            }
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
                
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}
```
```swift:SampleApp.swift
import SwiftUI
import SwiftData

@main
struct SampleApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Item.self,
        ])
        let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("item.json")
        let modelConfiguration = JSONStoreConfiguration(name: "JsonStore", fileURL: fileURL)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

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

</details>

このコードではDocuments配下に`item.json`ファイルが作成され、そのJSONファイルをデータストアとしています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/460869/9385330b-1314-4b46-be34-bca35fe8a9a5.png" width="400">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/460869/2f4e4882-37ed-4a6c-80cb-63130c8f61b6.png" width="600">

そのためJSONファイルを削除しない限りアプリキルをしてもデータは永続化されています。
また、以下のようにContentViewに渡すModelContainerを変えることでデータストアをデフォルトのCore Dataに切り替えることも可能です。
```swift:SampleApp.swift
import SwiftUI
import SwiftData

@main
struct SampleApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Item.self,
        ])
        let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("item.json")
//        let modelConfiguration = JSONStoreConfiguration(name: "JsonStore", fileURL: fileURL)
        let modelConfiguration = ModelConfiguration(isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

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

# 外部データベースを使ってみたい

WWDCのビデオやSwiftDataのページではWebサービスやデータベースエンジンなど、さまざまな種類の永続化に対応できると書かれています。
それならFirebaseだったりAPIを介して外部のデータベースをデータストアとして扱うことも可能なのでは？と思ったのですが、データを取得する`fetch`メソッドがasyncではなく、戻り値に表示したいデータを渡す必要があります。

そのため、通常は非同期処理を行う場合はカスタムデータストアとしてFirebaseや外部のデータベースを指定するのは難しいのかな、と思います。
（~~むしろAppleはどういうケースを想定してWebサービスやデータベースエンジンを例に挙げたんだろう...~~）

ただ、非同期処理の完了を待ってデータを渡すことができればいけるのでは？と思い試してみました。

## サンプルコード

コード全文は以下を見てください。
サーバーはHummingbirdでローカルにAPIサーバーを立てています。
~~データベースに関しては、今回APIサーバーからデータを返せればなんでもいいため採用していません。~~
(2024/4/22 追記)
SQLiteを使用するよう変更しました。

https://github.com/1mash0/SwiftData-Custom-Data-Store-Sample

変更点としては以下になります。

- リモート用のDataStoreConfigurationを作成
- リモート用のDataStoreを作成
- `.modelContainer`に作成したリモート用DataStoreConfigurationを指定

### ちょっと解説

```swift:RemoteDataStore.swift
final class RemoteDataStore: DataStore {
    ...
    func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
        var result: Result<[DefaultSnapshot], Error>!
        let semaphore = DispatchSemaphore(value: 0)
        
        Task {
            defer { semaphore.signal() }
            
            do {
                let snapshot: [DefaultSnapshot] = try await APIClient.fetch()
                result = .success(snapshot)
            } catch {
                print(error)
                result = .failure(error)
            }
        }
        semaphore.wait()
        
        return .init(descriptor: request.descriptor, fetchedSnapshots: try result.get())
    }

    func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
        let identifier = identifier
        let semaphore = DispatchSemaphore(value: 0)
        
        Task {
            defer { semaphore.signal() }
            
            do {
                let insertItems = try request.inserted.map {
                    let persistentIdentifier = try PersistentIdentifier.identifier(
                        for: identifier,
                        entityName: $0.persistentIdentifier.entityName,
                        primaryKey: UUID()
                    )
                    return $0.copy(persistentIdentifier: persistentIdentifier)
                }
                
                if !insertItems.isEmpty {
                    try await APIClient.register(insertItems)
                }
            } catch {
                print(error)
            }
        }
        semaphore.wait()
        
        return .init(for: identifier)
    }
}
```
これらの`fetch`と`save`は`DataStore`プロトコルの必須メソッドになります。
そのためメソッド自体をasyncにすることが不可能なため、`DispatchSemaphore`を使って非同期処理が完了するまで待機するようにしています。

それ以外の処理に関してはJSONファイルの時とほぼ同じで、読み取りor書き込みをAPIリクエストに置き換えただけになります。

## 結果

追加 & 取得できました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/460869/b113e103-7fda-4922-ba54-74c13de1548c.png" width="400">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/460869/81c2449b-fd6c-4bfb-8b19-7e86f96f5a49.png" width="600">

## 注意点

今回のサンプルコードでは`DataStoreSnapshot`を独自で定義せずに`DefaultSnapshot`を用いており、JSONファイルにも、APIレスポンスにも`persistentIdentifier`などのデータが自動で追加されています。

この`persistentIdentifier`ですが、`DataStoreSnapshot`プロトコルでrequiredとなっているため、独自のDataStoreSnapshotを定義しても必要となります。

JSONファイルでは特に意識する必要は無いのですが、APIサーバー側の実装時はリクエストで渡されるデータに`persistentIdentifier`などのデータが入ってることに注意しないとエンコードやデコードに失敗してしまいます。

そのため今回は同じフィールドを持つ構造体をAPIサーバー側で定義して無理矢理エンコード/デコードを成功するようにしています。

# おわりに
SwiftDataのカスタムデータストアで、JSONファイルとAPIを介した外部データベースの2種類を試しました。
感想としては個人的にイマイチ使い所が思い浮かばず...
（~~デフォルトのCore Dataでいいんじゃ...~~）

ただ、まだ発表されたばかりなのでこれから機能が拡張していけばもっと使いやすくなるのかな、と思います。

# 参考
https://developer.apple.com/jp/videos/play/wwdc2024/10138/
https://wwdcnotes.com/documentation/wwdcnotes/wwdc24-10138-create-a-custom-data-store-with-swiftdata/
https://qiita.com/Kyome/items/075ec302978c4d9670b4
https://theswiftdev.com/hummingbird-routing-and-requests/
https://youtu.be/lu0Ge0td1Kg?feature=shared