shiodaifuku.io

Collection Groupクエリをパスでフィルタリングするのはやめたほうがいい

この記事はしおだいふく Advent Calendar 2020 3日目の記事です。

たまにはFirebaseネタということで、今日Twitterでちょっと話題にとりあげたCollection Groupをクエリする際にドキュメントIDでフィルタする話です。 「ドキュメントのIDがフィルタの条件に使えるというのは重要な知見なのだが、実際に使うタイミングが現れたら先にスキーマ設計が間違っていることを疑ったほうがいい」と発言した背景について書きます。

ドキュメントIDによるフィルタとは

Firestoreのクエリでは、where句のフィルタ条件にfirestore.FieldPath.documentID()という値が指定できます。 これを使うと、ドキュメントIDを条件としたクエリを発行することができます。

Query.whereのドキュメントを見ると、 第1引数がstring | FieldPathという型の値を受け取ることになっています。 一般的なFieldPath型の値はドキュメントのデータ(フィールド)のいずれかを指しますが、通常はstringで十分用が足りるのでほとんど出番はありません。 FieldPath.documentID()は特別な値であり、データではなくドキュメントのIDをクエリの比較対象にすることができます。

以下のように使います使いませんが、説明のためにサンプルコードを載せておきます。

firebase.firestore().collection('articles')
  .where(firebase.firestore.FieldPath.documentId(), '==', 'EEMgJ2OnaPJ0NKUWZvQH')
  .get()

『使いません』というのは、上記の例で事前に条件として指定したいIDがわかっているならクエリなど使わずにドキュメントを直接取ればいいということです。

firebase.firestore().collection('articles')
  .doc('EEMgJ2OnaPJ0NKUWZvQH')
  .get()

こうですね。

Collection Groupで範囲フィルタをつかう?

Twitterで話したのはCollection Groupに対するクエリで、範囲フィルタをつかうケースについてです。 versions/{version}/samples/{id}のようなパス構成でドキュメントが格納されているとき、次のようなクエリを打つとversionの値でフィルタが可能というものです。

// versionがv2のドキュメントだけを取得する
const from = firebase.firestore().doc('versions/v2')
const to = firebase.firestore().doc('versions/v2!')

const snapshot = await firebase.firestore().collectionGroup('samples')
  .where(firebase.firestore.FieldPath.documentId(), '>=', from.path)
  .where(firebase.firestore.FieldPath.documentId(), '<', to.path)
  .get()

versions/{version}/samples/{id}に格納されているドキュメントのFieldPath.documentId()にあたる値は、 'versions/v2/samples/0WWHAEf0B000i2J9fdmA'のようにコレクション名とIDを全部つなげた値になります1

上記のコードが何をやっているかというと、『 (適当な階層にある全ての)samplesという名前のコレクションから、 FieldPath.documentId()の値が(辞書順で)'versions/v2'以上'versions/v2!'未満のドキュメントを取得する』、 つまり、おおよそFieldPath.documentId()'versions/v2'で始まり、かつ'samples'を含むドキュメントを得るクエリを発行しています。

見た目はスキーマのバージョニングをしたい、というような用途と思われますが、これも前節の例と同様にversionが予めわかっているなら小難しいことをせずに通常のクエリでOKです。

// versionがv2のドキュメントだけを取得する
const snapshot = await firebase.firestore().collection('versions/v2/samples')
  .get()

それ以外のケース検討

以上の議論を踏まえると、FieldPath.documentId()でフィルタするクエリを有効に利用できるのは、 Collection Groupクエリであること、かつパスに含まれるドキュメントIDが事前に特定できない状況であることが必要です。 (一般的なCollectionに対するクエリであれば、最近充実しているwhereのオペレータで適切に絞ればほとんどのケースで結果が同じになるクエリが発行できます。)

上記のバージョンの例で言えば、「バージョンIDがv2からv5のドキュメントを取得したい」とか「バージョンIDがv3ではないドキュメントを取得したい」とかですかね。 例があまり適切ではないので、実際にそういうクエリが必要になることはほとんどないと思われます。 ちょっと思いつかないのですが、もしかしたら役に立つケースがあるのかもしれません。

それはそれとして

Firestoreを利用されたことのあるかたならご存知かと思いますが、Firestoreでは適当にIDを発行すると20文字のランダム文字列になります。 ランダム文字列なので、FieldPath.documentId()を辞書順にならべてフィルタした結果には通常特別な意味はありません。 このようなクエリが意味を持つためには、ドキュメントIDの値に何かしら意味を与えたときに限られます。

さて、このブログの熱心な読者のみなさまであれば僕が昨日書いた記事を読んでいただいているのではないかと思います。 そうですね、PKに意味を与えるのはやめてくださいという話です。 FirestoreではドキュメントIDがPKに相当するので、原則としては素直にランダム文字列を使っておいてほしいです。

FieldPath.documentId()の範囲フィルタの話に戻りましょう。 先の例のバージョンIDのように高々数十個くらいしか発行されないIDであれば、範囲指定を使わないシンプルなクエリをforループで打てばよさそうです。 人が管理するのは難しいくらいの数IDが発行される(が、ソート可能なID体系を利用する)場合はホットスポットが発生する恐れがあるので、 Firestoreの使い方としてそもそも適切ではありません。 ドキュメントIDに意味をもたせるなという話を脇においておいたとしても、FirestoreではFieldPath.documentId()の範囲フィルタクエリに出番を与えるべきではないのです。

まとめ

以上のような理由で、クエリのフィルタ条件にFieldPath.documentId()がでてきたら怪しいなと思ってほしいです。 Firestoreでこのようなクエリが必要になったら、たぶんスキーマ設計が間違っています。

都合よく昨日の話につながるトピックになってよかったです。


  1. DocumentSnapshotやDocumentReferenceにした場合のidフィールド('0WWHAEf0B000i2J9fdmA')とは異なります

最新記事

  1. クソお世話になりました
  2. Cloud RunでgRPC streamingができるようになったので動かしてみたりした
  3. 2020年の執筆業まとめ
  4. アーキテクチャを設計する仕事とは
  5. 決済システムを設計するときに忘れてはならないたった1つの大切なこと