Firestore で Algolia を使わず「複数フィールド・複数カテゴリの検索」を実装する

## はじめに
こちらの記事を読みました。

**[Firestore だけで Algolia を使わず全文検索](https://qiita.com/oukayuka/items/d3cee72501a55e8be44a)**

非常に面白い記事で、生のFirestoreで[Algolia](https://www.algolia.com/)を使わずに全文検索を実現する方法が紹介されています。
 
今回は上記の記事で紹介されている方法を転用して、Firestoreだけでの実現が難しい「複数のフィールドーに対して複数のカテゴリを指定して検索」する機能の実装方法を紹介します。

## TL;DR
- `in`,`array-contains-any`を使って実現するのは難しそうだ
- 検索用のデータ構造を設計すれば、実現できる
- orderByは使えないけど、documentIDを使えば単一条件でページングも実装できる

## Firestoreでの検索
Firestoreでは、Documentのフィールドの条件指定や並び替えをしてデータを取得する為の[Query](https://cloud.google.com/firestore/docs/query-data/get-data?hl=ja)が提供されています。ただし、サーバーサイドでSQLを使用するのと比較すると、細かな検索はできません。

2020/05/18現在では、全文検索や`OR`句による検索などは提供されていませんし、提供されているクエリの使用時にもいくつか制限事項があります。詳しくは以下をご覧ください。

**参考**
- [データのクエリとフィルタ #制限事項](https://cloud.google.com/firestore/docs/query-data/queries?hl=ja#limitations)
- [データのクエリとフィルタ #クエリの制限事項](https://cloud.google.com/firestore/docs/query-data/queries?hl=ja#limitations)
 
## Firestoreでのカテゴリ検索
今回行いたいのは「複数フィールドに対する複数カテゴリによる絞り込み」です。

例えば、あるコミュニティサービスがあったとして、ユーザーの「趣味」と「好きな音楽ジャンル」で検索をかけたい、として実現方法を考えていきます。

#### 前提条件
- 各フィールドに登録可能なカテゴリ数は有限数とします。実際に運用するサービスだとすると、ユーザーが登録できるカテゴリ数の合計は１００程度だとします。
    - Firestoreの制限で、単一ドキュメントに保存できる[データの容量の制限](https://firebase.google.com/docs/firestore/data-model?hl=ja)があるので注意してください。無限に増え続けるようなデータは本記事では想定していません。

以下のようにusersコレクションにUserドキュメントが格納されているとします。

```userDocument.json
{
   "users":[
      {
         "userID":{
            "hobbies":[
               "soccer",
               "shopping"
            ],
            "favoriteMusicGenre":[
               "jpop",
               "rock"
            ]
         }
      }
   ]
}
```

(上記では、`soccer`や`rock`など直接カテゴリ名を格納していますが、IDとかでもいいです)

### 素直にクエリする方法を考えてみる
#### `in`クエリか`array-contains-any`を使う
Firestoreでは、arrayフィールドに指定した値が含まれているかを条件とする`in`クエリと、指定した配列内の要素がフィールドの値にマッチするかを条件とする`array-contains-any`クエリが提供されています。
 
要件にも依りますが、これらのクエリを使って以下のような実装方法が考えられます。
 
##### 各フィールドの`where`句の条件に値を一つ指定する
```queryByValue.ts
const snapshot = await usersCollection
    .where("hobbies", "in", "soccer")
    .where("favoriteMusicGenre", "in", "jpop")
    .get()
```
##### 各フィールドの`where`句の条件に配列を指定する
```queryByArray.ts
const snapshot = await usersCollection
    .where("hobbies", "array-contains-any", ["soccer", "shopping"])
    .where("favoriteMusicGenre", "array-contains-any", ["jpop", "rock"])
    .get()
```
 
上手くいきそうですが、上記はどちらも使えません。次の理由です。
 
- [クエリの制限事項](https://cloud.google.com/firestore/docs/query-data/queries?hl=ja#limitations)により、`in`,`array-contains-any`は一つのクエリにつき一回しか使えない
- そもそも`in`は、配列フィールドに対しては使えない
 
### クエリ用のデータ構造を考える
上記のように、素直に「複数フィールドに対する複数カテゴリによる絞り込み」を実装するのは難しい、と分かりました。
ここで、もちろん[Algolia](https://www.algolia.com/)を使うのもありですが、今回はFirestoreだけを使って実現する方法を考えてみます。

[Firestore だけで Algolia を使わず全文検索](https://qiita.com/oukayuka/items/d3cee72501a55e8be44a)を参考に、クエリする為に以下のようなデータ構造を考えました。


下記のデータ構造では、当初のusersコレクションの設計ではarrayだった`hobbies`と`favoriteMusicGenre`を、各要素をkeyとしてvalueに`true`を格納するMapにしています。

```userQueries.json
{
   "userQueries":[
      {
         "userQueryID":{
            "userID": "Xjkfaik3fkadkfjst"
            "hobbies":[
               {
                  "soccer":true,
                  "shopping":true
               }
            ],
            "favoriteMusicGenre":[
               {
                  "jpop":true,
                  "rock":true
               }
            ]
         }
      }
   ]
}
```

#### 実際のクエリ
上記のようなデータ構造にすることで、以下のようなクエリで検索ができます。

```categoryQuery.ts
// 検索条件
const hobbyParams = ["soccer", "shopping"]
const musicGenreParams = ["jpop", "rock"]

let query = userQueriesCollection as Query

// クエリ追加処理
hobbyParams.forEach((hobby) => {
    query = query.where(`hobbies.${hobby}`, "==", true)
})
musicGenreParams.forEach((genre) => {
    query = query.where(`favoriteMusicGenre.${genre}`, "==", true)
})

const snapshot = await query.get()
```

#### ページング
今回の方法では、Firestoreでよく用いられる、ドキュメントを最新更新時刻で`orderBy`するような方法は使えません。
　
しかし、ドキュメントはID順に取得されるので、各ドキュメントの`documentID`をTimestamp値を元にして作成しておけば、Timestamp順に取得することが出来ます。

(その他の順序で取得したい場合は、用途ごとに新たにコレクションを作成して、取得したい順序に`documentID`を調整する必要があります。)

where句で`FieldPath.documentId`を指定しつつ`limit`と組み合わせれば、ページングが可能です。

```categoryQueryPaging.ts
let query = userQueriesCollection as Query

// 一つ前の例のクエリ追加処理
...

// 一度読み込んだドキュメントがあれば、最後に取得したドキュメントIDより後を指定
if(lastDocumentID){
    query = query.where(FieldPath.documentId(), ">", lastDocumentID)
}

// 取得数を制限するlimitを追加
const snapshot = await query.limit(20).get()
```

このように複数のフィールドに対して、複数のカテゴリを指定した検索をすることができます。

## クエリ用のコレクションを用意する
上記のデータ構造を用いることで「複数のフィールドに対して、複数のカテゴリを指定した検索」ができますが、実際のアプリケーションで表示ロジックなどに用いる際には非常に扱いにくいです。(データ構造が検索ロジックに引きずられているのも良くないはず)

なので

**ユーザー情報を格納するコレクションとは別で、クエリ用のコレクション(e.g.`userQueries`)を用意するのが良いと思います。**
 
クエリ用のコレクションを別にするので、以下を前提として設計するとすっきり作れそうです。

- **検索機能でのユーザー情報取得はクライアントサイドジョインで行う**
    - クエリ用ドキュメント取得後`userID`フィールドを用いてuserデータを取得してジョインするようにする。
- **Cloud Functionsのトリガーを利用する**
    - usersコレクションに変更があったときに、onUpdate/onCreate トリガーでクエリ用のサブクレクションを更新するようにします。

上記のようにすることで、クライアントアプリはクエリの書き込みをしなくて良くなり、また、検索ロジックを外部サービスに移行することになったとしても、比較的少ないコストで移行できるはずです。

実装方法の説明は以上です。

## この方法の pros & cons
[Firestore だけで Algolia を使わず全文検索](https://qiita.com/oukayuka/items/d3cee72501a55e8be44a)で紹介されているpros/consとほとんど同じです。

##### cons
- Firestoreでよく用いる`orderBy`を用いた並べ替えができません。
    - ただ`documentID`を工夫すれば、条件ごとに並び順を調整することは出来そうです。
    
##### pros
- 速い
    - 体感かなり速いです
    - コレクション内のドキュメント数が多い場合のパフォーマンスは不明です

# まとめ
以上、Firestore だけで Algolia を使わず複数フィールド複数カテゴリでの検索方法について、ご紹介しました。
最後まで読んでくださって有り難うございました。
　
今回の方法には欠点もありますが、個人での開発などAlgoliaやElasticsearchを用いるのはコストが見合わない場面もあるので、こちらの方法を使っていきたいと思います。
 
ご意見・質問はコメントかTwitterでよろしくお願いします。

https://twitter.com/heavenOSK