あるサブコレクションが別のサブコレクションにまたがって存在する場合
次のようなFirestoreのコレクションとドキュメントの関係がある場合を考えます。
- users (collection)
- user-id-1 (document)
- albums (sub collection)
- alubm-id-1 (document)
- songs (sub collection)
- song-id-1 (document)
- song-id-2 (document)
- album-id-2 (document)
- songs (sub collection)
- song-id-3 (document)
- song-id-4 (document)
- user-id-2 (document)
- albums (sub collection)
- album-id-3 (document)
- songs (sub collection)
- song-id-5 (document)
- song-id-6 (document)
- album-id-4 (document)
- songs (sub collection)
- song-id-7 (document)
- song-id-8 (document)
一番上にusers
コレクションがあり、その下に各users
ドキュメントが存在します。
そのusers
ドキュメントは、それぞれにalbums
とsongs
というサブコレクションが存在します。
そしてそのサブレコレクションの下にもドキュメントが存在するという構造になっています。
このときに、users/user-id-1
のsongs
のドキュメントのみを取得したい場合どのようにすればよいのでしょうか?
コレクションとドキュメントを横断的に取得できる「コレクショングループ」という機能
Firestoreにおけるクエリはとても浅いものとなっています。
そのため、ひとつのクエリを使って横断的に複数のコレクションやドキュメントを取得することは原則できません。
しかし、Firestoreではコレクショングループという機能が提供されています。これを使えば横断的にコレクションやドキュメントを取得することができます。
使い方は次のようになります。
val query = firestore.collectionGroup("songs").whereEqualTo("category", "jpop")
query.get().await()
このクエリーの場合、songs
という名前のすべてのコレクションに含まれるすべてのドキュメントを取得することができます。
コレクショングループにおける問題点
実は、このクエリーにはひとつ問題点があります。
それは、あるユーザーが他のユーザーのsongs
サブコレクションの内容まで横断的に取得できてしまうというものです。
例えば、user-id-2
のユーザーが、自身のsongs
サブコレクションのみを取得しようとして上記のクエリを実行した場合、user-id-1
のsongs
サブコレクションのドキュメントまで取得できてしまうのです。
パスを使った解決方法
実は、上記の問題を回避する方法が存在します。それはパスと、orderby()
、startAt()
、そしてendAt()
を組み合わせて構築するクエリーの方法です。
実際のコードは次のようになります。
val usersRef = firestore.collection("users").document(userId)
val query = firestore.collectionGroup("songs")
.orderBy(FieldPath.documentId())
.startAt(usersRef.path)
.endAt(usersRef.path + "\uf8ff")
val snapshot = query.get().await()
このクエリーであれば、songs
ドキュメントに"users/$userId"
を表すDocumentReference
型のフィールドを持たせることなく、users/$userId
配下に存在するsongs
サブコレクションのドキュメントを横断的に取得することができるようになります。
そして上記のクエリーの結果、user-id-2
のユーザーが所有する、song-id-5
、song-id-6
、sond-id-7
、song-id-8
のsongs
ドキュメントのみがsnapshot
として返ってきます。
この時、orderBy()
を使わないとクエリーが正しく実行されないので注意してください。必ず、最初にorderBy()
を実行してからstartAt()
とendAt()
をそれぞれ順番に呼び出してください。
\uf8ffをエンドガードとして使う理由
Firebaseの公式ドキュメントによると、\uf8ff
はユニコード上で普段使用される通常の文字の後ろに存在する値のため、クエリで使用するとstartAt()
で指定した値で始まるすべての値に一致するクエリを作成することができるそうです。
例えば、"/users/user-id-1"
というDocumentReferenceをstartAt()
に渡して、"/users/user-id-1" + "\uf8ff"
をendAt()
に渡すことでクエリの両端を制限することができます。
これにより、"/users-/user-id-1"
で始まるパスを持つすべてのドキュメントを取得することができるようになります。
val usersRef = firestore.collection("users").document(userId)
val collectionReference = firestore.collectionGroup("albums")
.orderBy(FieldPath.documentId())
.startAt(usersRef)
.endAt(usersRef + "\uf8ff")
まとめ
Firestoreは、通常のRDMSと比べてクエリーの部分が浅く、慣れ親しんだMySQLやPostgreSQLと同じような結果を期待している場合に、思っていた結果と違う内容になってしまい苦労することが多々あると思います。
しかし、近年はコレクショングループのような機能が追加されるなど、昔に比べてサブコレクションを使っても1:n
の関係を維持しながらドキュメントを取得できるようになってきた気がします。
RDMSと比べて、銀の弾丸と呼ばれるFirestoreのベストプラクティスはまだ確立されていませんが、こういうものだと割り切った上でFirestoreと上手く付き合っていく方が良いのかもしれないと最近は思いなが開発をしています。
もし、上記の方法以外に横断的にサブコレクションを取得するよい方法があれば、コメントをいただけると助かります。
参考にした記事