🐣

Firestoreで多対多のrelationを作るなど

2024/11/16に公開

モバイルアプリ作ってみたいと思い、FlutterとFirestoreの素振りを始めました。
Key Value Storeの使い方(というかRDB以外のデータベースほとんど)についてちゃんと考えたことがなかったので、基本っぽいところから結構苦労しました。

やったこと

  • KVS (Firestore)で多対多のデータ構造を作る。
  • ネストさせたStreamBuilderでhas_manyのさらにhas_manyの情報を全部表示する。

前提

以下のデータ構造を作ることを考えます。(Rails風)
グループで保有する持ち物の管理みたいなイメージです。

group has_many users
user has_many groups
user has_many items
item belongs_to user

RDBの場合、groups、users、itemsのそれぞれに対応するテーブルと、groupとuserの中間テーブルあたりを作ればもう終わりって感じですね。

Firestoreでやる

KVSのメリットの一つはキーを指定すれば子要素も一度に取得できることですが、
素直に多対多の関係を作ろうとすると、同じデータを複数作らないといけなくなりそうです。(非正規化)
色々考えた結果、配列型のフィールドにreferenceを格納する方法に落ち着きました。(KVSのメリットは放棄)

// 特定のgroupに属するuser
CollectionReference users = FirebaseFirestore.instance.collection('users').where('groups', arrayContains: groupDocumentRef)
// 特定のuserが属しているgroup
CollectionReference groups = FirebaseFirestore.instance.collection('groups').where('users', arrayContains: userDocumentRef)

これで相互に参照できます。
データの整合性を保つための処理コストは比較的小さいはず。多分

has_manyのhas_manyを取ってくる

groupAに属するuser全員の持つitemを全て表示したいとき、groupのサブコレクションに情報を持っていれば、groupのキーを一つ指定するだけで取得できますね。(KVSのメリット)
今回はそんな構成にしていないので、違うやり方を使います。

FlutterのWidgetでデータを表示することを考えます。
StreamBuilderをネストさせて実現させました。

まず2個streamを準備します。

Stream<List<DocumentReference>> _getUserIdsStream(DocumentReference groupRef) {
  return groupRef
    .snapshots()
    .map((snapshot) {
      List<dynamic> userRefs = snapshot['users'] ?? [];
      return userRefs.map((userRef) => (userRef as 
      DocumentReference)).toList();
    });
}

Stream<QuerySnapshot> _getItemsStream(List<DocumentReference> userIds) {
  return FirebaseFirestore.instance.collection('items').where('user', whereIn: userIds).snapshots();
}

上記のstreamをStreamBuilderに設定します。

StreamBuilder(
  stream: _getUserIdsStream(groupRef),
  builder: (context, userIdsStream) {
    if (userIdsStream.hasData) {
      final userIds = userIdsStream.data!;
      return StreamBuilder(
        stream: _getItemsStream(userIds),
        builder: (context, AsyncSnapshot<QuerySnapshot> streamSnapshot) {
          if (streamSnapshot.hasData) {
            return ListView.builder(
              itemCount: streamSnapshot.data!.docs.length,
              itemBuilder: (context, index) {
                final DocumentSnapshot documentSnapshot = streamSnapshot.data!.docs[index];
                return ListTile(
                  title: Text(documentSnapshot['name']),
                );
              }
            );
          }
          return const Center(
            child: CircularProgressIndicator(),
          );
        },
      );
    }
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
)

まとめ

今回のケースはどちらかというと整合性の方が大事なので大人しくRDBを使っておいた方がよさそうだと思いました。
基本はRDBを使い、特にリアルタイム性が求められる箇所だけなるべくシンプルなデータ構造にした上でFirestoreを使うのがよさそうです。

Discussion