🏢

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

2022/11/10に公開

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

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

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アプリでは利用可能ですが、アプリの種類によっては必要ない場面も多々あるのも現実です。

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

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

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

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

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

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

モデル実装

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

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

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

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

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

利用方法

// 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();

インストール

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

flutter pub add katana_model

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

flutter pub add katana_model_local

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

flutter pub add katana_model_firestore

構造

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

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

ドキュメント

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

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

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

コレクション

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

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

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

パスによる配置

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

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

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

// `User` collection.
/user

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

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

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

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

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

実装

事前準備

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

// 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});

  
  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を渡せるようにします。

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

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

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

コレクション

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

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

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

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

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

freezedを利用した場合

freezedを利用するとスキーマを定義でき、さらに安全に実装することが可能です。


class UserValue with _$UserValue {
  const factory UserValue({
    required String first,
    required String last,
    (1900) int born
  }) = UserValue;

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

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

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

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

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

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

Recordを利用した場合

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

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

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

  
  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をタップするとフィールドの内容がランダムに更新され、削除ボタンをタップすると要素が削除されるコードは下記のようになります。

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);

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

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

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

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

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

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

  
  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});

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

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

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

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

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

  
  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を変更することでデータモック用のデータベースからローカルDBFirestoreに切り替えることができます。

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

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

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

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

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を定義し検索対象となるテキストを作成します。

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

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

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

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

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

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

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

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

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

これで準備完了です。

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

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機能と同じような形でトランザクションを実行することが可能です。

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

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

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

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

ModelTransactionRef.read(ドキュメント)でドキュメントをModelTransactionDocumentに変換します。

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

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

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

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"}

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

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で処理完了を待つことができます。

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に対応します。サーバー側の同期された時刻でタイムスタンプを保存したい場合に利用します。
    • 引数に値を渡すことで日付の指定が可能ですが、サーバー上に渡されたときにサーバーの時刻に同期されます。
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を利用する場合は、ModelCounterModelTimestampそのものの型で定義してください。


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

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

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

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

  
  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>をミックスインします。

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

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

  
  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にフィールドの参照型からどのドキュメントをどの値に渡すかを定義します。

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

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

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

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

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

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

// {
//   "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>を作成して渡してください。

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

freezedを利用する場合は、ModelRef<T>そのものの型で定義してください。

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

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


class UserValue with _$UserValue {
  const factory UserValue({
    required String name,
    required String text,
  }) = UserValue;

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


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);

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

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

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

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

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

  
  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を保有しており、こちらにすべてのデータが格納されます。

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

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

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"});
});

おわりに

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

また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!

また仕事の依頼等ございましたら、私のTwitterWebサイトで直接ご連絡をお願いいたします!

https://mathru.net/ja/contact

GitHub Sponsors

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

https://github.com/sponsors/mathrunet

Discussion