【Firestore】「orWhere」が使えない時の代案のまとめ
この記事は、「Firestore のような NoSQL で orWhere が使えないときの、代案をまとめた記事」です。
背景
Firestore を使っている現場で orWhere が必要なケースが出てきました。 ですが、Firestore は orWhere がサポートされていません。(2019/9 時点)
NoSQL なので、ここらへんは仕方ないですが、どうにか代案を考えて実現はしないといけないので、そのときに考えた結果をまとめました。
(追記: 2019-09-09) また、公式ドキュメント - Cloud Firestore で単純なクエリと複合クエリを実行する | Firebaseにて、言及されていました。
論理 OR クエリ。この場合は、OR 条件ごとに独立したクエリを作成し、アプリでクエリ結果を結合する必要があります。
ただ、後述の案 ③ なら、ビューフィールドを用意する必要はありますが、クエリレイヤーで解決できます。
ユースケース
////// Entity
// firestore:users/{user}
type user = {
id: string;
// ...
};
// firestore:messages/{message}
type message = {
id: string;
senderID: string; // = userID
receiverID: string; // = userID
createdAt: Date;
updatedAt: Date;
};
<ユースケース>
- 「自分が投げた Message」+「自分が受け取った Message」のリストを表示
- updatedAt の DESC 順
orWhere の 代案
出てきた案として下記 3 つです。 (追記:さらに 1 個追加。実装終わってから気付いたのが悔やまれる(2019/9/6))
【案 ①】クエリを2つ投げて、アプリレイヤーで1つの List に結合して、最後にソート。
- メリット:データを持つ必要がない ⇐ データ整合性を気にしないで良い
- デメリット:結合やデータ取得のロジックが複雑になる
【案 ②】検索用のビューをコレクションに作る
- メリット:データ取得のロジックが案 ① よりも簡素になる。
- デメリット:データを保持するので、データ整合性を担保しておかないといけない
- ⇐Firebase を使っているケースなら、CloudFunctions のトリガーも使えると思うので、データ整合性の担保を取りやすい
- デメリット:検索用コレクションで絞ったリストを元にクエリを投げるが、Firestore には whereIN もない(2019/9 時点)。なので、N+1 回のクエリが必要なので fetch 回数が多い
- ⇐Firestore なので、むしろそういうもの、だど割りきる。
【案 ③】検索用のビューをフィールドに Array で作る
- メリット:クエリレイヤーですべて解決できる
- デメリット:トリガーの発火元と更新先が自分自身なので、無限ループする可能性を少し秘めている。
- ただ、そこまで気にするほどではないかと思う
- デメリット:データを保持するので、データ整合性を担保しておかないといけない(案 ② と同じ。)
結論ですが、案 ② の方針にしました。案 ① だと無限スクロールとの併用が特につらそうだったので。
今なら、案 ③ で実装します。実装が終わった後に、array-contains の機能を知ったので気付かなかったです。
では、1つずつみていきます。
【案 ①】2 つクエリを投げて、1 つに結合
だいたいこんな感じになります。
const me = "<Userモデルの自分のデータ>";
const lastQ1Visible = "<q1の前回取得分の最後>";
const lastQ2Visible = "<q2の前回取得分の最後>";
const snap1 = await db
.collection("messages")
.where("senderID", "==", me.id)
.orderBy("updatedAt")
.limit(10)
.startAt(lastP1Visible)
.get();
const snap2 = await db
.collection("messages")
.where("receiverID", "==", me.id)
.orderBy("updatedAt")
.limit(10)
.startAt(lastP2Visible)
.get();
const [q1, q2]: Message[] = [snap1, snap2].map((snap) => ({
id: snap.id,
...snap.data(),
})); // 結果をentity に格納
const result = sortDesc([...q1, ...q2]); // `sortDesc` はupdatedAt で ソートをしてくれるようなヘルパーの想定
実際は結合する前にきちんと「ソート条件とデータ順番」についての確認してあげないといけないです。
例えば
- p1 の条件のデータ群の 20 件は最近の分
- p2 の条件のデータ群の 20 件は 5 年前の分
- Limit がそれぞれ 10 件ずつ
となると、1 回目の fetch 結果が、
「最近の p1 の 10 件」+「5 年前の p2 の 10 件」 を updatedAt でソートした結果
になってしまいます。
なので、結合して良いかを確認して、結合不可の場合はキャッシュの変数に格納しておいて、次の fetch タイミングではそこから取って、みたいな処理にしないといけなさそうです。
更に、無限スクロールが入ってくると、ここらへんの処理が複雑になるかと思います。
この案は、「ページネーション がないような、一度ですべてのデータを取得できるようなケース」のときに良さそうです。
【案 ②】検索用のビューをコレクションに作る
下記のような Model でのコレクションを作っておきます。
// firestore:viewableMessages/{viewableMessage}
type ViewableMessage = {
messageID: string;
userID: string; // <= senderID or receiverID
createdAt: Date;
updatedAt: Date;
};
関係性として、1 つの Message に対して、2 つの ViewableMessage の Document が存在する関係です。
digraph G{
compound=true
node [shape=folder]
comment [label="ここの範囲で検索する", shape=none, fontsize=8]
text [shape=point, fontsize=8]
subgraph cluster_M {
label="Message"
M1 [label="・senderID\n・receiverID"]
}
subgraph cluster_VM {
label="ViewableMessage";
labelloc="b"
labeljust="l"
labelfloat="false"
bgcolor="gray";
margin=30
VM1 [label="userID=senderID"]
VM2 [label="userID=receiverID"]
}
M1->text
text->VM1
text->VM2
comment -> VM1 [lhead=cluster_VM]
}
データ生成については後述しますが、ひとまずデータも用意されている前提で話を進めます。
実装は、下記のようになるかと思います。
const me = "<Userモデルの自分のデータ>";
const lastVisible = "<前回取得分の最後>";
// ①viewableMessageから取得。これで、ほしいMessageIDの一覧がわかる。
const snaps1 = db
.collection("viewableMessages")
.where("userID", "==", me.id)
.orderBy("updatedAt")
.limit(10)
.startAt(lastVisible)
.get();
const viewableMessages: ViewableMessage[] = snaps1.map((snap) => ({
id: snap.id,
...snap.data(),
}));
// ②viewableMessage が持つMessageIDの、Messagesをすべて取得。
const snaps2 = viewableMessages.map((viewableMessage) =>
db.collection("messages").doc(viewableMessage.messageID).get()
);
const result = snaps2.map((snap) => ({ id: snap.id, ...snap.data() }));
案1に比べて、案2はロジックも少なく、Pagination・無限スクロールなども入れやすい形ですね。
ただ、① + ② で N+1 の回数分だけクエリが発行されてしまいます。ただ、Firestore なので、「そういうもの」だとと思っているので、ここは個人的には許容範囲です。
案 ② のデータ保持
さて、案 ② の ViewableMessage ですが、message とデータ整合性を取らないといけません。
特に、今回のユースケースが updatedAt
の順番なので、message の値が更新されたら、viewableMessage の updatedAt
も更新してあげないといけません。
こういうときには、CloudFunctions のトリガーを使うのがベターだと思っています。
digraph G{
MessageModel
Trigger [shape=box]
ViewableMessageModel
MessageModel -> Trigger [ label ="「データ作成 or 更新」を\n検知して発火"]
Trigger -> ViewableMessageModel [label="「作成」 or \n 「updatedAt を更新」"]
}
こんな感じで、データ整合性の担保をします。
今回のケースでは、一方方向のデータ依存性なので、トリガーが連鎖してループする可能性も少ないかと思います。
【案 ③】検索用のビューをフィールドに Array で作る (追加)
Message に viewableUserIDs などの userID のリストを保持するフィールドを用意します。
type message = {
id: string;
senderID: string;
receiverID: string;
viewableUserIDs: string[]; // <<< これを追加
createdAt: Date;
updatedAt: Date;
};
そして、格納するデータはviewableUserIDs = [senderID, receiverID]
として、閲覧可能な User の ID を入れておきます。
これなら、Create するときに、合わせて作成できるので、わざわざトリガーにする必要もないですね。
そして、検索条件として、array-contains
を使います。array-contains
は、Array の中に任意の値が含まれていれば fetch してくれる、リスト内検索機能です。
const me = "<Userモデルの自分のデータ>";
const lastQueryVisible = "<前回取得分の最後>";
const snap = await db
.collection("messages")
.where("viewableUserIDs", "array-contains", me.id) // << こいつ
.orderBy("updatedAt")
.limit(10)
.startAt(lastQueryVisible)
.get();
const message: Message = { id: snap.id, ...snap.data() };
案 ①、案 ② よりも、かなりスッキリしましたね。
案 ③ のデータ保持
また、仮に「送信先の User を変更可能にする」という要件が出てきたとします。
このとき、Message の中の receiverID の変更に合わせて、viewableUserIDs の値も変更しないといけません。
変更する際は、案 ② と同じくトリガーを使う想定で考えます。
このとき、案 ② とは異なり、自己参照の関係になるので、トリガーの条件に注意しておかないと、トリガーがループする可能性がある点です。
つまり、下記が同じ Message になる、ということです。
- トリガー発火の元の Document
- トリガー発火で起きる更新先の Document
digraph G {
M [ label="Message"]
M -> M [ label="トリガーでviewableUserIDsを更新"]
}
とはいえ、フィールドごとに見れば、単方向の関係性なので、そこまで問題はないかと思います。
digraph G {
subgraph cluster_Message{
label=Message
senderID -> V
receiver -> V [ label="トリガーでviewableUserIDsを更新"]
V [label=viewableUserIDs]
}
}
所感
案 ② で、実体ありビューを作成することになりますが、トリガーを使えば管理コストもそこまで高くならないかと思います。
実装が終わってから、案 ③ に気付いたのが悔やまれる。