【Flutter】モデル実装をシンプルかつスムーズに行えるパッケージを作った

こんにちは。広瀬マサルです。

皆さんデータはどのように管理されてますでしょうか？

RDBやNoSQL、SQLiteやSharedPreferencesなどが主に使われてるのではないでしょうか？

その中でも公式にサポートされFlutter内で一番使われているデータベースであろうFirestoreがあります。

これをメインに使う人にとって（多分）嬉しいパッケージを作りました。

私がアプリ開発を行う場合下記のパターンが多いです。

- 依頼者がサーバーを利用したくない、必要性がない場合はアプリ端末内にデータを保存
- 依頼者がサーバーを利用する、必要性がある場合はFirestoreにデータを保存
- サーバーに繋げる前にモックアプリでUIデザインを確認してもらう場合が多いが、そのためのデータモックを用意する必要がある
- ロジック部分のテストを行う場合にもデータモックを用意する必要がある
- というかモデル部分書くの面倒

このあたりをサクッと解決してくれます。

使い方をまとめたので興味ある方はぜひ使ってみてください！

# katana_model

https://pub.dev/packages/katana_model

https://pub.dev/packages/katana_model_firestore

# はじめに

データの読み書きの実装は割と面倒です。

RDB+RestAPIだと、スキーマに合わせた実装を行う必要がありますしローカルに保存するだけでも読み書きの処理を実装するだけで骨が折れます。

Firestoreなどのシンプルに実装できる強力なデータベースがモバイル・Webアプリでは利用可能ですが、アプリの種類によっては必要ない場面も多々あるのも現実です。

様々なアプリを開発してきた経験上下記の機能を有しているモデルがあれば９割型アプリを作れるんじゃないかと考えています。

- CRUD（Create、Read、Update、Delete）が行える
- データ構造はMap（Dictionary）型のオブジェクトとそのリストがあればOK
- Like検索と簡単なクエリフィルターが利用できる
- テスト、モックアップ、ローカルDB、リモートDBを利用できる。

FirestoreをリモートDBの軸にしながら、ローカルDB、モックアップ・テスト用DBをインターフェースを揃えて利用可能にすることで実現可能になることがわかりました。

そのためそれらを実現するために下記のようなパッケージを作りました。

- インターフェースやデータ構造をFirestoreに合わせたものにし簡略化。簡単に利用できるようにした。
    - CRUDを行うだけのシンプルなインターフェース。
    - モデル側の実装が容易。
- アダプターを変更することで`データモック`、`ローカルデータベース`、`Firestore`を簡単に切り替えることが可能。
    - インターフェースが同一なのでどこにつなげているかを意識せずに実装することが可能。
    - データモック用のアダプターを利用することでテストコードも容易に実装可能。
- フォロー・フォロワー機能が実装しやすいトランザクション機能、簡易的なLike検索が可能（勿論Firestore上でも）な機能の提供
- Firestoreでは必須なClientJoin（特定のドキュメントフィールドに埋め込まれたドキュメントリファレンス先を参照してさらに読込）を容易に組み込める機能の提供
- [freezed](https://pub.dev/packages/freezed)などのImmutableなクラスを利用しやすく安全に実装できる構造
    - [freezed](https://pub.dev/packages/freezed)を利用するとFirestoreのスキーマを定義することができます
- [Provider](https://pub.dev/packages/provider)や[riverpod](https://pub.dev/packages/riverpod)などと組み合わせて利用しやすいChangeNotifierを継承した構造

下記のように少ないコードで安全にモデル部分を実装することができます。

## モデル実装

```dart
class DynamicMapDocument extends DocumentBase<Map<String, dynamic>> {
  DynamicMapDocument(super.modelQuery);

  @override
  Map<String, dynamic> fromMap(Map<String, dynamic> map) => map

  @override
  Map<String, dynamic> toMap(Map<String, dynamic> value) => value;
}

class DynamicMapCollection extends CollectionBase<DynamicMapDocument> {
  DynamicMapCollection(super.modelQuery);

  @override
  DynamicMapDocument create([String? id]) {
    return DynamicMapDocument(modelQuery.create(id));
  }
}
```

## 利用方法

```dart
// Create
final doc = collection.create();
doc.save({"first": "masaru", "last": "hirose"});

// Read
await collection.load();
collection.forEach((doc) => print(doc.value));

// Update
doc.save({"first": "masaru", "last": "hirose"});

// Delete
doc.delete();
```

# インストール

下記のパッケージをインポートします。

```dart
flutter pub add katana_model
```


ローカルDBを利用する場合は下記のパッケージを合わせてインポートします。

```dart
flutter pub add katana_model_local
```

Firestoreを利用する場合は下記のパッケージを合わせてインポートします。

```dart
flutter pub add katana_model_firestore
```

# 構造

CloudFirestoreのデータモデルをベースにしています。

https://firebase.google.com/docs/firestore/data-model

## ドキュメント

値にマップされるフィールドを含む軽量のレコードです。

Dart上では`Map<String, dynamic>`に相当します。

```dart
<String, dynamic>{
  first : "Ada"
  last : "Lovelace"
  born : 1815
}
```

## コレクション

複数のドキュメントを含んだリストです。

Dart上では`List<Map<String, dynamic>>`に相当します。

```dart
<Map<String, dynamic>>[
  <String, dynamic>{
    first : "Ada"
    last : "Lovelace"
    born : 1815
  },
  <String, dynamic>{
    first : "Alan"
    last : "Turing"
    born : 1912
  },
]
```

## パスによる配置

`/collection/document`のようなパス構造でデータが配置され、パスを指定することによりデータを取得することができます。

パスの上から数えて**奇数個目がコレクション**、**偶数個目がドキュメント**を指定するパスになることを覚えておいてください。

※最初の`/`は記載してもしなくても同じパスとして扱われます。

```dart
// `User` collection.
/user

// "Ada" user's document in `User` collection.
/user/ada
```

## リアルタイムアップデート

このパッケージではリアルタイムアップデートがデフォルトで動作します。

外部（もしくは内部）でデータの変更が行われた場合、関連するすでに読み込んでいるドキュメントやコレクションが自動で更新されモデルに通知されます。

モデルはすべて`ChangeNotifier`を継承しており、更新を`addListener`などで監視していればWidgetの再描画を即座に行うことが可能です。

# 実装

## 事前準備

`ModelAdapterScope`をMaterialAppの上などに配置し`ModelAdapter`を指定してください。

```dart
// main.dart
import 'package:flutter/material.dart';
import 'package:katana_scoped/katana_scoped.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ModelAdapterScope(
      adapter: const RuntimeModelAdapter(), // Adapters used only within the application on execution for mockups, etc.
      child: MaterialApp(
        home: const ScopedTestPage(),
        title: "Flutter Demo",
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
      ),
    );
  }
}
```

## モデル作成

下記のように格納する値を指定してドキュメントとコレクションのクラスを作成します。

この例では`Map<String, dynamic>`を利用します。

### ドキュメント

`DocumentBase<T>`を継承して`T fromMap(Map<String, dynamic> map)`、`Map<String, dynamic> toMap(T value)`を実装します。

コンストラクタに`modelQuery`を渡せるようにします。

```dart
class DynamicMapDocument extends DocumentBase<Map<String, dynamic>> {
  DynamicMapDocument(super.modelQuery);

  @override
  Map<String, dynamic> fromMap(Map<String, dynamic> map) => map

  @override
  Map<String, dynamic> toMap(Map<String, dynamic> value) => value;
}
```

### コレクション

`CollectionBase<TDocument extends DocumentBase>`を継承して`TDocument create([String? id])`を実装します。

`TDocument create([String? id])`内で関連するドキュメント（ここでは`DynamicMapDocument`）を作成して返す処理を実装します。

コンストラクタに`modelQuery`を渡せるようにします。

```dart
class DynamicMapCollection extends CollectionBase<DynamicMapDocument> {
  DynamicMapCollection(super.modelQuery);

  @override
  DynamicMapDocument create([String? id]) {
    return DynamicMapDocument(modelQuery.create(id));
  }
}
```

### freezedを利用した場合

[freezed](https://pub.dev/packages/freezed)を利用するとスキーマを定義でき、さらに安全に実装することが可能です。

```dart
@freezed
abstract class UserValue with _$UserValue {
  const factory UserValue({
    required String first,
    required String last,
    @Default(1900) int born
  }) = UserValue;

  factory UserValue.fromJson(Map<String, Object?> json)
      => _$UserValueFromJson(json);
}

class UserValueDocument extends DocumentBase<UserValue> {
  DynamicMapDocument(super.modelQuery);

  @override
  UserValue fromMap(Map<String, dynamic> map) => UserValue.fromJson(map);

  @override
  Map<String, dynamic> toMap(UserValue value) => value.toJson();
}

class UserValueCollection extends CollectionBase<UserValueDocument> {
  UserValueCollection(super.modelQuery);

  @override
  UserValueDocument create([String? id]) {
    return UserValueDocument(modelQuery.create(id));
  }
}
```

### Recordを利用した場合

Dart3から利用可能になったRecordを利用することも可能です。

```dart
class UserRecordDocumentModel
    extends DocumentBase<({String first, String last, int? born})> {
  RuntimeRecordDocumentModel(super.query);

  @override
  ({String first, String last, int? born}) fromMap(DynamicMap map) {
    return (
      born: map.get("born", 0),
      first: map.get("first", ""),
      last: map.get("last", ""),
    );
  }

  @override
  DynamicMap toMap(({String first, String last, int? born}) value) {
    return {
      "born": value.born,
      "first": value.first,
      "last": value.last,
    };
  }
}
```

# 利用方法

CRUDに従って、下記のメソッドが用意されています。

- データ新規作成：`create()`
- データ読み込み：`load()`
- データ更新：`save(T value)`
- データ削除：`delete()`

ただし、以下の制限があります。

- データ作成はコレクションの`create()`の実行、もしくはドキュメントを直接パス指定して作成するのみとする
- データ読み込みはコレクション、およびドキュメントの`load()`を利用する
- データ更新はドキュメントの`save(T value)`のみとする。コレクションをまとめて更新は不可（コレクションをループしてそれぞれのドキュメントに`save(T value)`を実行することは可能）
- データ削除はドキュメントの`delete()`のみとする。コレクションをまとめて更新は不可（コレクションをループしてそれぞれのドキュメントに`delete()`を実行することは可能）

また、通知に関しては以下のルールがあります。

- ドキュメントのフィールドが変更された場合は、該当するドキュメントのみに通知される。
- コレクション内のドキュメントが追加・削除される（つまりコレクション内のドキュメント数が増減する）場合は、該当するコレクションに通知される。

コレクションの要素をリストで表示し、FABで要素を追加、各ListTileをタップするとフィールドの内容がランダムに更新され、削除ボタンをタップすると要素が削除されるコードは下記のようになります。

```dart
import 'dart:math';

import 'package:katana_model/katana_model.dart';

import 'package:flutter/material.dart';

class ModelDocument extends DocumentBase<Map<String, dynamic>> {
  ModelDocument(super.modelQuery);

  @override
  Map<String, dynamic> fromMap(DynamicMap map) => map;

  @override
  DynamicMap toMap(Map<String, dynamic> value) => value;
}

class ModelCollection extends CollectionBase<ModelDocument> {
  ModelCollection(super.modelQuery);

  @override
  ModelDocument create([String? id]) {
    return ModelDocument(modelQuery.create(id));
  }
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ModelAdapterScope(
      adapter: const RuntimeModelAdapter(),
      child: MaterialApp(
        home: const ModelPage(),
        title: "Flutter Demo",
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
      ),
    );
  }
}

class ModelPage extends StatefulWidget {
  const ModelPage({super.key});

  @override
  State<StatefulWidget> createState() => ModelPageState();
}

class ModelPageState extends State<ModelPage> {
  final collection = ModelCollection(const CollectionModelQuery("/user"));

  @override
  void initState() {
    super.initState();
    collection.addListener(_handledOnUpdate);
    collection.load();
  }

  void _handledOnUpdate() {
    setState(() {});
  }

  @override
  void dispose() {
    super.dispose();
    collection.removeListener(_handledOnUpdate);
    collection.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Flutter Demo")),
      body: FutureBuilder(
        future: collection.loading ?? Future.value(),
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
          return ListView(
            children: [
              ...collection.mapListenable((doc) { // Monitor Document and redraw only the content widget when it is updated.
                return ListTile(
                  title: Text(doc.value?["count"].toString() ?? "0"),
                  trailing: IconButton(
                    onPressed: () {
                      doc.delete();
                    },
                    icon: const Icon(Icons.delete),
                  ),
                  onTap: () {
                    doc.save({
                      "count": Random().nextInt(100),
                    });
                  },
                );
              }),
            ],
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          final doc = collection.create();
          doc.save({
            "count": Random().nextInt(100),
          });
        },
      ),
    );
  }
}
```

まず、コレクションのパスを`CollectionModelQuery`で指定しながらコレクションのオブジェクトを作成し保持しておきます。

`addListner`などで監視し更新されたら`setState`で画面を再描画します。

（この例の場合、コレクションにドキュメントが追加、削除されたときのみModelPageが再描画されます）

`collection.load()`でデータの読込を行ないます。

読込に時間がかかる場合、`collection.loading`がFutureになっているのでそのまま`FutureBuilder`などに渡すことによりローディングインジケーターを実装することが可能です。

`collection`そのものがListのインターフェースを実装しているので、`collection`のデータ読込が完了するとforループやmapなどのメソッドで中のドキュメントを取得することができます。

このとき`mapListenable`を使って中のコールバックでWidgetを返すようにするとドキュメントの変更を監視し、`ドキュメントのフィールドが更新されたときに該当のWidgetだけを再描画`することが可能になります。

ドキュメントの値を更新する場合は、`doc.save(T value)`のvalueに更新された値を渡して実行するだけです。

ドキュメントを削除する場合は、`doc.delete()`を実行するだけです。ドキュメントが削除された場合、関連するコレクションにも通知されModelPageが再描画されます。

ドキュメントを新規で追加する場合、`collection.create()`でドキュメントを新規作成し`doc.save(T value)`で値を更新します。

`collection.create()`だけではコレクションにドキュメントが追加されず、`doc.save(T value)`を実行したタイミングでコレクションにドキュメントが追加されます。

# 利用するデータベースの切り替え

`ModelAdapterScope`に渡すModelAdapterを変更することでデータモック用のデータベースから`ローカルDB`、`Firestore`に切り替えることができます。

※Firestoreを利用する場合は、事前にFirebaseプロジェクトの作成や設定のインポート等を行ってください。

https://firebase.flutter.dev/docs/firestore/overview

現在は以下のアダプターが利用可能です。

- `RuntimeModelAdapter`
    - アプリが起動中のみデータを保存するデータベースアダプターです。
    - アプリが停止、再起動した場合はすべてのデータが失われます。
    - アプリ起動時にデータを仕組むことが可能で、`データのモックアップ`、`テストでの利用`が可能です。
- `LocalModelAdapter`
    - 端末ローカルにデータを保存するデータベースアダプターです。
    - アプリが停止、再起動した場合でもデータが保持されます。
    - アプリを削除、再インストールした場合は、データが失われます。
    - 端末に保存されているデータは暗号化されアプリのみでしか開けないようになっています。
    - サーバーを利用したくない・必要ない場合に利用可能です。
- `FirestoreModelAdapter`
    - `CloudFirestore`にデータを保存するデータベースアダプターです。
    - アプリが停止、再起動、削除、再インストールした場合でもデータが保持されます。
    - サーバーへのデータ保存、サーバーを介した他ユーザーとのコミュニケーションを行う場合に利用可能です。
- `ListenableFirestoreModelAdapter`
    - `CloudFirestore`にデータを保存するデータベースアダプターです。
    - アプリが停止、再起動、削除、再インストールした場合でもデータが保持されます。
    - サーバーへのデータ保存、サーバーを介した他ユーザーとのコミュニケーションを行う場合に利用可能です。
    - Firestoreの`リアルタイムアップデート機能`を用いサーバー側での更新をアプリ側に即座に伝えます。
        - チャット機能の実装等にお使いください。
- `CsvCollectionSourceModelAdapter`、`CsvDocumentSourceModelAdapter`
    - CSVをデータソースにして扱うことができるデータソースアダプターです。
    - **値の保存、削除は行えません。**
    - 下記の方法でCSVを取得することが可能です。
        - ソースコードに直接記載
        - `assets`フォルダ以下に格納し読み込み
        - URLからの取得（Webに公開済みのGoogleスプレッドシートなど）

```dart
// Use local database
ModelAdapterScope(
  adapter: const LocalModelAdapter(), // Adapter for reading and saving in local DB.
  child: MaterialApp(
    home: const ScopedTestPage(),
    title: "Flutter Demo",
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
  ),
)

// Use firestore database
ModelAdapterScope(
  adapter: FirestoreModelAdapter(options: DefaultFirebaseOptions.currentPlatform), // Adapter for using Firestore, which can switch the connection destination by giving options.
    home: const ScopedTestPage(),
    title: "Flutter Demo",
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
  ),
)
```

# 応用した使い方

## コレクションクエリ

Firestoreと同じような形で`コレクションクエリ`で要素をフィルタリングすることができます。

コレクションのオブジェクトを作成する際に渡す`CollectionModelQuery`を各種メソッドで繋げていくことによってフィルタリングの条件を指定できます。

```dart
final collection = ModelCollection(
  const CollectionModelQuery(
    "/user",
  ).greaterThanOrEqual("born", 1900)
);
```

各メソッドの`key`に対象となるフィールド名（**DB上に保存されているもの。toMap後に変換された`Map<String, dynamic>`のキー**）を指定します。

フィルタリングの条件は下記を指定することが可能です。

メソッドを繋げることで複数指定も可能ですが**Adapterによっては利用不可な組み合わせもあります**。

- `equal(key, value)`：指定された値と同じになる値を持つドキュメントのみを返します。
- `notEqual(key, value)`：指定された値と同じではない値を持つドキュメントを返します。
- `lessThan(key, value)`：指定された値より低い値を持つドキュメントを返します。
- `lessThanOrEqual(key, value)`：指定された値と同じ、もしくは低い値を持つドキュメントを返します。
- `greaterThan(key, value)`：指定された値より高い値を持つドキュメントを返します。
- `greaterThanOrEqual(key, value)`：指定された値より同じ、もしくは高い値を持つドキュメントを返します。
- `contains(key, value)`：対象となる値がリスト形式の場合、指定された値が含まれるドキュメントを返します。
- `containsAny(key, values)`：対象となる値がリスト形式の場合、指定されたリストのいずれかが含まれるドキュメントを返します。
- `where(key, values)`：対象となる値が指定されたリストに含まれるドキュメントを返します。
- `notWhere(key, values)`：対象となる値が指定されたリストに含まれないドキュメントを返します。
- `isNull(key)`：対象となる値がNullであるドキュメントを返します。
- `isNotNull(key)`：対象となる値がNullでないドキュメントを返します。

また合わせて値をソートして取得数を制限することが可能です。

- `orderByAsc(key)`：指定したキーに対する値を昇順にソートします。
- `orderByDesc(key)`：指定したキーに対する値を降順にソートします。
- `limitTo(value)`：数を指定するとコレクション内にそれ以上の数のドキュメントが存在したとしても指定された数に制限して返します。

## テキスト検索

Bigramを利用してFirestore内で検索可能なデータ構造を作成し、Firestoreのみでコレクション内のテキスト検索（Like検索）を可能にします。

（RuntimeModelAdapterやLocalModelAdapterでも利用可能です）

まず作成するドキュメントに`SearchableDocumentMixin<T>`をミックスインします。

その際に`buildSearchText`を定義し検索対象となるテキストを作成します。

下記の例では`name`と`text`のフィールドに含まれる文字列を検索対象としています。

```dart
class SearchableMapDocument extends DocumentBase<Map<String, dynamic>>
    with SearchableDocumentMixin<Map<String, dynamic>> {
  SearchableMapDocument(super.query);

  @override
  Map<String, dynamic> fromMap(Map<String, dynamic> map) => map;

  @override
  Map<String, dynamic> toMap(Map<String, dynamic> value) => value;

  @override
  String buildSearchText(DynamicMap value) {
    return (value["name"] ?? "") + (value["text"] ?? "");
  }
}
```

次に検索を行うコレクションに`SearchableCollectionMixin<TDocument>`をミックスインします。

この場合、`TDocument`は`SearchableDocumentMixin<T>`をミックスインしている必要があります。

```dart
class SearchableMapCollection
    extends CollectionBase<SearchableMapDocument>
    with SearchableCollectionMixin<SearchableMapDocument> {
  SearchableMapCollection(super.query);

  @override
  SearchableMapDocument create([String? id]) {
    return SearchableMapDocument(
      modelQuery.create(id),
      {},
    );
  }
}
```

これで準備完了です。

`SearchableMapDocument`に必要なデータを渡して保存し、`SearchableMapCollection`の`search`メソッドを利用することで検索を行うことが可能です。

```dart
final query = CollectionModelQuery("user");

final collection = SearchableMapCollection(query);
final queryMasaru = DocumentModelQuery("user/masaru");
final modelMasaru = SearchableMapDocument(queryMasaru);
await modelMasaru.save({
  "name": "masaru",
  "text": "vocaloid producer",
});
final queryHirose = DocumentModelQuery("user/hirose");
final modelHirose = SearchableMapDocument(queryHirose);
await modelHirose.save({
  "name": "hirose",
  "text": "flutter engineer",
});
await collection.search("hirose");
print(collection); // [{ "name": "hirose", "text": "flutter engineer",}]
```

## トランザクション

Firestoreの`transaction`機能と同じような形でトランザクションを実行することが可能です。

複数のドキュメントの更新を１つにまとめることが可能で、フォロー・フォロワー機能のような`各ドキュメントがそれぞれ相互して相手の情報を登録する`といった実装が可能です。

トランザクションを行うにはドキュメントやコレクションの`transaction()`メソッドを実行して`ModelTransactionBuilder`を生成する必要があります。

生成された`ModelTransactionBuilder`はそのまま実行することができ、そのコールバック内でトランザクション処理を記述します。

コールバックには`ModelTransactionRef`と元のドキュメント（コレクション）が渡されます。

`ModelTransactionRef.read（ドキュメント）`でドキュメントを`ModelTransactionDocument`に変換します。

`ModelTransactionDocument`はデータの読込（`load()`）、保存（`save(T value)`）、削除（`delete()`）を行えます。

ただし**必ずデータの読込（`load()`）の後に保存（`save(T value)`）、削除（`delete()`）を行ってください**。

保存と削除処理は`ModelTransactionBuilder`のコールバック処理が終わった後に実行され、awaitで処理完了を待つことができます。

```dart
final myDocument = ModelDocument(const DocumentModelQuery("/user/me/follow/you"));
final yourDocument = ModelDocument(const DocumentModelQuery("/user/you/follower/me"));

final transaction = myDocument.transaction();
await transaction(
  (ref, doc) {
    final myDoc = ref.read(doc);
    final yourDoc = ref.read(yourDocument);

    myDoc.save({"to": "you"});
    yourDoc.save({"from": "me"});
  },
);
print(myDocument.value); // {"to": "you"}
print(yourDocument.value); // {"from": "me"}
```

`DocumentBase`の`extension`を利用することによってトランザクション処理をまとめることが可能です。

```dart
extension FollowFollowerExtensions on DocumentBase<Map<String, dynamic>> {
  Future<void> follow(DocumentBase<Map<String, dynamic> target) async {
    final tr = transaction();
    await tr(
      (ref, doc) {
        final me = ref.read(doc);
        final tar = ref.read(target);
		
        me.save({"to": tar["id"]});
        tar.save({"from": me["id"]});
      },
    );
  }
}

final myDocument = ModelDocument(const DocumentModelQuery("/user/me/follow/you"));
final yourDocument = ModelDocument(const DocumentModelQuery("/user/you/follower/me"));
await myDocument.follow(yourDocument);
```

## バッチ処理

Firestoreの`batch`機能と同じような形でバッチ処理を実行することが可能です。

複数のドキュメントを一度に実行することが可能で、パフォーマンスに優れます。

一度に数千、数万のデータ更新を行いたい場合に実行してください。

バッチを行うにはドキュメントやコレクションの`batch()`メソッドを実行して`ModelBatchBuilder`を生成する必要があります。

生成された`ModelBatchBuilder`はそのまま実行することができ、そのコールバック内でバッチ処理を記述します。

コールバックには`ModelBatchRef`と元のドキュメント（コレクション）が渡されます。

`ModelBatchRef.read（ドキュメント）`でドキュメントを`ModelBatchDocument`に変換します。

`ModelBatchDocument`はデータの保存（`save(T value)`）、削除（`delete()`）を行えます。

保存と削除処理は`ModelBatchBuilder`のコールバック処理が終わった後に実行され、awaitで処理完了を待つことができます。

```dart
final myDocument = ModelDocument(const DocumentModelQuery("/user/me/follow/you"));
final yourDocument = ModelDocument(const DocumentModelQuery("/user/you/follower/me"));

final batch = myDocument.batch();
await batch(
  (ref, doc) {
    final myDoc = ref.read(doc);
    final yourDoc = ref.read(yourDocument);

    myDoc.save({"to": "you"});
    yourDoc.save({"from": "me"});
  },
);
print(myDocument.value); // {"to": "you"}
print(yourDocument.value); // {"from": "me"}
```

## 特殊なフィールド値

Firestoreにはいくつかの`FieldValue`が用意されています。端的に言うとクライアント側では処理しきれない処理を`FieldValue`を渡すことによりサーバー側で処理をさせうまく動作するようにする機能です。

katana_modelではそれに対応できるような特殊なフィールド値を用意しました。

- `ModelCounter`
    - `FieldValue.increment`に対応します。「いいね」機能などでいいねされた数を確実にカウントしたい場合などに利用します。
    - `increment(int i)`のメソッドを利用することで`i`の値増減させることが可能です。
- `ModelTimestamp`
    - `FieldValue.serverTimestamp`に対応します。サーバー側の同期された時刻でタイムスタンプを保存したい場合に利用します。
    - 引数に値を渡すことで日付の指定が可能ですが、サーバー上に渡されたときにサーバーの時刻に同期されます。

```dart
const query = DocumentModelQuery("/test/doc");
final model = ModelDocument(query);
await model.save({
  "counter": const ModelCounter(0),
  "time": ModelTimestamp(DateTime(2022, 1, 1))
});
print((model.value!["counter"] as ModelCounter).value); // 0
print((model.value!["time"] as ModelTimestamp).value); // DateTime(2022, 1, 1)
await model.save({
  "counter": (model.value!["counter"] as ModelCounter).increment(1),
  "time": ModelTimestamp(DateTime(2022, 1, 2))
});
print((model.value!["counter"] as ModelCounter).value); // 1
print((model.value!["time"] as ModelTimestamp).value); // DateTime(2022, 1, 2)
```

[freezed](https://pub.dev/packages/freezed)を利用する場合は、`ModelCounter`と`ModelTimestamp`そのものの型で定義してください。

```dart
@freezed
abstract class UserValue with _$UserValue {
  const factory UserValue({
    required String first,
    required String last,
    @Default(1900) int born
    @Default(ModelCounter(0)) ModelCounter likeCount,
    @Default(ModelTimestamp()) ModelTimestamp createdTime,
  }) = UserValue;

  factory UserValue.fromJson(Map<String, Object?> json)
      => _$UserValueFromJson(json);
}

class UserValueDocument extends DocumentBase<UserValue> {
  DynamicMapDocument(super.modelQuery);

  @override
  UserValue fromMap(Map<String, dynamic> map) => UserValue.fromJson(map);

  @override
  Map<String, dynamic> toMap(UserValue value) => value.toJson();
}

final userDocument = UserValueDocument(const DocumentModelQuery("user/masaru"));
await userDocument.load();
print(userDocument.value.likeCount.value); // 0
await userDocument.save(
  userDocument.value.copyWith(
    likeCount: userDocument.value.likeCount.increment(1),
  )
);
print(userDocument.value.likeCount.value); // 1
```

## リファレンスフィールド

例えば、`user`コレクションでユーザーデータを管理しており、`shop`コレクションでショップデータを管理しているとしましょう。

`shop`の管理者を`user`で定義したい場合、shopドキュメントから関連するuserドキュメントを参照したほうが、`user`の変更を`shop`でも反映することができるため効率的です。

Firestoreでは`Reference`型という別のドキュメントを参照するための型があり、クライアント側で追加でデータを読み取ることが可能です。

katana_modelでは予め宣言しておく形でそれらの関係性を定義し自動でデータの読込を行ないます。

まず、**参照先**のドキュメントに対して、`ModelRefMixin<T>`をミックスインします。

```dart
class UserDocument extends DocumentBase<Map<String, dynamic>>
    with ModelRefMixin<Map<String, dynamic>> {
  UserDocument(super.query);

  @override
  Map<String, dynamic> fromMap(Map<String, dynamic> map) => map;

  @override
  Map<String, dynamic> toMap(Map<String, dynamic> value) => value;
}
```

続いて**参照元**のドキュメントに対して、`ModelRefLoaderMixin<T>`をミックスインし、`List<ModelRefBuilderBase<TSource>> get builder`を実装します。

`List<ModelRefBuilderBase<TSource>> get builder`には`ModelRefBuilder<TSource, TResult>`のリストを定義します。

この`ModelRefBuilder`にフィールドの参照型からどのドキュメントをどの値に渡すかを定義します。

下の例では、`ShopDocument`の`user`というフィールドに`UserDocument`を入れる定義を記載しています。

```dart
class ShopDocument extends DocumentBase<Map<String, dynamic>>
    with ModelRefLoaderMixin<Map<String, dynamic>> {
  ShopDocument(super.query);

  @override
  Map<String, dynamic> fromMap(Map<String, dynamic> map) => map;

  @override
  Map<String, dynamic> toMap(Map<String, dynamic> value) => value;

  @override
  List<ModelRefBuilderBase<DynamicMap>> get builder => [
        ModelRefBuilder(
          modelRef: (value) => value.getAsModelRef("user", "/user/doc"),
          document: (modelQuery) => UserDocument(modelQuery),
          value: (value, document) {
            return {
              ...value,
              "user": document,
            };
          },
        ),
      ];
}
```

これで下記のように自動でデータが取得され、リアルタイムアップデートされます。

```dart
// {
//   "user/doc": {"name": "user_name", "text": "user_text"},
//   "shop/doc": {"name": "shop_name", "text": "shop_text"}
// }

final user = UserDocument(const DocumentModelQuery("user/doc"));
final shop = ShopDocument(const DocumentModelQuery("shop/doc"));
await user.load();
await shop.load();
print(user.value); // {"name": "user_name", "text": "user_text"}
print(shop.value); // {"name": "shop_name", "text": "shop_text", "user": UserDocument({"name": "user_name", "text": "user_text"})}
```

新規に参照を作りたい場合は、`ModelRef<T>`を作成して渡してください。

```dart
shop.value = {
  ...shop.value,
  "user": ModelRef<Map<String, dynamic>>(const DocumentModelQuery("user/doc2")),
};
```

[freezed](https://pub.dev/packages/freezed)を利用する場合は、`ModelRef<T>`そのものの型で定義してください。

constではないので、`@Default`で初期値を入力することはできません。`required`をつけるか`?`をつけてnullableにしてください。

`ModelRefBuilder`はより簡潔に書けます。

```dart
@freezed
abstract class UserValue with _$UserValue {
  const factory UserValue({
    required String name,
    required String text,
  }) = UserValue;

  factory UserValue.fromJson(Map<String, Object?> json)
      => _$UserValueFromJson(json);
}

@freezed
abstract class ShopValue with _$ShopValue {
  const factory ShopValue({
    required String name,
    required String text,
    ModelRef<UserValue>? user,
  }) = ShopValue;

  factory ShopValue.fromJson(Map<String, Object?> json)
      => _$ShopValueFromJson(json);
}

class UserValueDocument extends DocumentBase<UserValue> with ModelRefMixin<UserValue> {
  DynamicMapDocument(super.modelQuery);

  @override
  UserValue fromMap(Map<String, dynamic> map) => UserValue.fromJson(map);

  @override
  Map<String, dynamic> toMap(UserValue value) => value.toJson();
}

class ShopValueDocument extends DocumentBase<ShopValue> with ModelRefLoaderMixin<ShopValue> {
  DynamicMapDocument(super.modelQuery);

  @override
  UserValue fromMap(Map<String, dynamic> map) => UserValue.fromJson(map);

  @override
  Map<String, dynamic> toMap(UserValue value) => value.toJson();

  @override
  List<ModelRefBuilderBase<ShopValue>> get builder => [
        ModelRefBuilder(
          modelRef: (value) => value.user,
          document: (modelQuery) => UserValueDocument(modelQuery),
          value: (value, document) {
            return value.copyWith(
              user: document,
            );
          },
        ),
      ];
  
}

// {
//   "user/doc": {"name": "user_name", "text": "user_text"},
//   "shop/doc": {"name": "shop_name", "text": "shop_text"}
// }

final user = UserValueDocument(const DocumentModelQuery("user/doc"));
final shop = ShopValueDocument(const DocumentModelQuery("shop/doc"));
await user.load();
await shop.load();
print(user.value); // {"name": "user_name", "text": "user_text"}
print(shop.value); // {"name": "shop_name", "text": "shop_text", "user": UserValueDocument({"name": "user_name", "text": "user_text"})}
```

## 単体テスト

モデルを絡めたロジック部分の単体テストを行ないたい場合、`RuntimeModelAdapter`を利用します。

`RuntimeModelAdapter`は内部で`NoSqlDatabase`を保有しており、こちらにすべてのデータが格納されます。

`NoSqlDatabase`を`RuntimeModelAdapter`のdatabaseの引数にて渡すことによりテスト内で閉じたデータを扱うことが可能です。

また、`RuntimeModelAdapter`には`rawData`を渡せるので、そこで初期値を設定することが可能です。

```dart
test("runtimeDocumentModel.test", () async {
  final adapter = RuntimeModelAdapter(
    database: NoSqlDatabase(),
    rawData: const {
      "test/doc": {"name": "aaa", "text": "bbb"},
    },
  );
  final query = DocumentModelQuery("test/doc", adapter: adapter);
  final document = ModelDocument(query);
  await document.load();
  expect(document.value, {"name": "aaa", "text": "bbb"});
});
```

# おわりに

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet