Firebase + FlutterでWhereNotInを考える

# やりたいこと

Firebase + Flutterのアプリケーション内でFireStore上にあるデータの `特定のグループ内の特定のユーザーの投稿は表示しない` のような仕様を満たしたい場合を考える。

```markdown
- groups [collection]
  - group 1
    - comments [collection]
      - comment 1
        - text: xxxx
        - uid: A
        - created_at: 2019-10-11 10:00:00
      - comment 2
        - text: xxxx
        - uid: B
        - created_at: 2019-10-11 10:00:00
      - comment 3
        - text: xxxx
        - uid: C
        - created_at: 2019-10-11 10:00:00
      - ...
  - group 2
    - ...
```

例えば上記のような構造があったとする。
`comments` には膨大な量のデータが有り、`uid: B のコメント以外のコメントがほしい` ケースを考える。これがなかなかに厄介で、MySQL的なRDB脳ではできないことが多く、結果としてクライアントに吸収する形になった :cry:  が、なぜそうなったか？そこに至ったまでに色々学びがあったので共有したい。案が5つあるうちで1,2は検証するも失敗、3を導入し、4,5はメリデメを整理した結果調査のみとした。

全体的にFireStore強めの記事になりすいません・・・

# まず確認したこと

MySQL的な `WhereNotIn` 的な概念はFireStoreには存在しない。
[FireStore Flutter SDK](https://github.com/FirebaseExtended/flutterfire/blob/master/packages/cloud_firestore/cloud_firestore/lib/src/query.dart#L121)

```dart
Query where(
    dynamic field, {
    dynamic isEqualTo,
    dynamic isLessThan,
    dynamic isLessThanOrEqualTo,
    dynamic isGreaterThan,
    dynamic isGreaterThanOrEqualTo,
    dynamic arrayContains,
    List<dynamic> arrayContainsAny,
    List<dynamic> whereIn,
    bool isNull,
}) {...
```

を見るとわかるように条件として設定できるQueryのレパートリーは少なく、かつ**OR条件というものは存在しない。**

これは[公式のドキュメント](https://firebase.google.com/docs/firestore/query-data/queries?hl=ja)でも言及されている。

> 論理 OR クエリ。この場合は、OR 条件ごとに独立したクエリを作成し、アプリでクエリ結果を結合する必要があります。

という前提知識の元書きを読み進めてほしい。

（賢いあなたは「オッ！whereInあるやんいけるやん！」と思うだろう、詳細は下記へ :runner: ）

# 各アイデアの調査

一通り検討していく。

## 案1. uid検索用のテーブルを作り必要なuidだけwhereで絞り込む

```
- groups [collection]
  - group 1
    - users: [A, B, C]  <--- ここを追加する
    - comments [collection]
      - comment 1
        - text: xxxx
        - uid: A
        - created_at: 2019-10-11 10:00:00
      - comment 2
        - text: xxxx
        - uid: B
        - created_at: 2019-10-11 10:00:00
      - comment 3
        - text: xxxx
        - uid: C
        - created_at: 2019-10-11 10:00:00
      - ...
  - group 2
    - ...
```

というケースを最初にメンバーと相談して調べ始めました。
実装としては修正も軽微だし、割と使われそうなコレクションだしまぁあってもいいなと思ったんですが、問題は `OR条件が存在しない` ということ。
これによりこの案はお蔵入り。

### `whereIn` の問題点

上述の通り、`whereIn`が存在してこれを使えばいけるように見えます。最新のSDKよりサポートされているため、0.12.11以上へ上げましょう。（この際、podに関するエラーが出ることがあり[こちらのissue](https://github.com/FirebaseExtended/flutterfire/issues/1562)で対策が議論されています。）
[ブログ](https://firebase.googleblog.com/2019/11/cloud-firestore-now-supports-in-queries.html?linkId=76644965)でもハイテンションで語られており、全ユーザーが歓喜したんですが

> As we mentioned earlier, you're currently limited to a maximum of 10 different values in your queries.

という成約があります。つまりクエリは**10件までしか**指定できません。
今回のケースでなければ便利に使えそうですが、groupのuidは全然10件超えるので厳しい・・・

では仮にFlutterで10件以上渡してやろうとするとどうなるか？

>  [VERBOSE-2:ui_dart_state.cc(148)] Unhandled Exception: PlatformException(invalid_query, FIRInvalidArgumentException, Invalid Query. ‘in’ filters support a maximum of 10 elements in the value array.)

**クラッシュします。** :clap: 

## 案2. 連番のuseridを発行して、And条件でフィルターできるようにする

「なればAnd条件もりもりでやったらー！！！」という気持ちのアイデアが以下。

```
- users [collection]
  - user 1
    - uuid: A
    - suid: 0 <- ここに連番のuseridを新しく定義して入れて
  - user 2
    - uuid: B
    - suid: 1
  - user 2
    - uuid: B
    - suid: 2
  ...
- groups [collection]
  - group 1
    - users: [A, B, C] 
    - comments [collection]
      - comment 1
        - text: xxxx
        - uid: A
        - suid: 0 <- commentのドキュメントにも追加
        - created_at: 2019-10-11 10:00:00
      - comment 2
        - text: xxxx
        - uid: B
        - suid: 1
        - created_at: 2019-10-11 10:00:00
      - comment 3
        - text: xxxx
        - uid: C
        - suid: 2
        - created_at: 2019-10-11 10:00:00
      - ...
  - group 2
    - ...
```

これで何ができるかというと。[公式の特定の範囲の数値型の条件を取得しない方法](https://firebase.google.com/docs/firestore/query-data/queries?hl=ja)にあるような

> != 句が含まれるクエリ。この場合は、「より大きい」クエリと「より小さい」クエリに、クエリを分割する必要があります。たとえば、クエリ句 where("age", "!=", "30") はサポートされませんが、2 つのクエリ（句 where("age", "<", "30") が含まれるクエリと句 where("age", ">", 30) が含まれるクエリ）を結合することで、同じ結果セットが得られます。

を踏襲して、ユーザーを識別する Authenticate から発行される文字列の uuid ではなく連番で数値型になるような値をもたせてこれを条件とします。つまり

> B以外のコメント = suidが2以上 AND 1未満

のようにクエリを発行できるのでANDで表現できます。ただこれもやはりAND条件の限界があり複数条件に対応できません。連番でuidを発行するのも RDS でいうところの Auto Incrementはないのでcloud functionでuserを生成する時に毎回コレクション数をとってくる必要があります。

### 他のクエリがあるとそもそも実現できない

この条件のみでクエリを入れるならいいんですが、これに加えて `created_atがxxx以上のもの` という成約を入れてしまうと[複合クエリの制約条件](https://firebase.google.com/docs/firestore/query-data/queries?hl=ja) にひっかかりクエリの生成ができません。

```js
// 公式のダメな例
citiesRef.where("state", ">=", "CA").where("population", ">", 100000)
```

で書かれるような範囲条件を指定するクエリは複数のキーを指定できません。

## 案3. フロントでどうにかする

:lifter: おまたせ :lifter: 

結局こうなりましたね・・・どんな設計にするにせよ実現はできる。すでに有識者が[検証とアイデア](https://snamiki1212.com/summary-alt-plan-insted-of-wherein-about-firestore)も公開してくれていますね、ありがたい。

手法としては `特定件数取得してフィルターする` `N+1回リクエストする` などの何パターン化が考えられるかと思います。ページングするか、readしている必要はあるかなどのユースケースによって分ければ良いと思います。

メリデメは後述。

## 案4. Algoliaでどうにかする

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Algoliaというサービスにfirestoreのデータをシンクすると検索機能を強化出来ますよ。<br>たしか not in も出来たはず。<br>（すでにご存知だったらすみません）<a href="https://t.co/oNURo9GvjY">https://t.co/oNURo9GvjY</a><a href="https://t.co/rZzHHW0mR9">https://t.co/rZzHHW0mR9</a></p>&mdash; 高松智明 (@t14i_) <a href="https://twitter.com/t14i_/status/1203680172978737153?ref_src=twsrc%5Etfw">December 8, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

感謝。この案があったかという感じ。
確かに[クエリ](https://www.algolia.com/doc/api-reference/api-parameters/filters/)を見ていると `OR` があり複数キーのrangeにも対応している。メリデメは後述。
 

## 案5. RESTのAPI作っちゃう

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">フロントで弾くことにすると、もし多量のデータになった時に通信料含め負担大きくなる+Firestoreのキャッシュ無駄に食いつぶすので、listenでなくてgetで取得するような感じならCloud Functionsを挟んであげた方がまだよいかもしれないです。少数程度のドキュメントであれば誤差ではありますが。</p>&mdash; const su- = エンジニア | 論理無職 (@_sgr_ksmt) <a href="https://twitter.com/_sgr_ksmt/status/1203958957569527808?ref_src=twsrc%5Etfw">December 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

cloud functionsでRESTのエンドポイントを建てるのではどうか？というのもありかなという話をtwitterでしました。インターネッツは困っていると助けてくれる人もいてありがたい・・・

メリデメは後述。

# 全体としてのメリデメの比較とまとめ

かんたんにメリデメをまとめます。

| 案       |       メリット |    デメリット    |
|:-----------------|:------------------|:------------------|
| 案1. uid検索用のテーブルを作り必要なuidだけwhereで絞り込む             |              条件が10件以下なら可 |        条件が11件以上だと無理        |
| 案2. 連番のuseridを発行して、And条件でフィルターできるようにする           |            条件が他にないなら可 |       複合条件に対応しない       |
| 案3. フロントでどうにかする             |   思想としてシンプルではある |        余計なデータを取得する必要があり、メモリ・キャッシュ的に非効率。        |
| 案4. Algoliaでどうにかする               |    柔軟に対応できるし必要なデータのみリクエストできる |         コスト         |
| 案5. RESTのAPI作っちゃう             | 余計なデータをリクエストしないでいい | 一部だけRESTになってフロントに実装差異が生まれる、CFでやるが結局そこでリクエストはすることになる|



ケースによっては1,2もありではありますね。今回は3で進めつつ、4か5に徐々に変えていこうと思います。それでは電子レンジにチキンを潜影蛇手(せんえいじゃしゅ） :snake:  してくるのでこの辺で。