サブクエリを使用して結合を実行する

概要

Firestore Enterprise エディションは、相関サブクエリ を使用してリレーショナル スタイルの結合をサポートしています。多くの場合、データの非正規化や複数のクライアントサイド リクエストの実行が必要となる多くの NoSQL データベースとは異なり、サブクエリを使用すると、関連するコレクションまたはサブコレクションのデータをサーバー上で直接結合して集計できます。

サブクエリは、外部クエリによって処理されるドキュメントごとにネストされたパイプラインを実行する式です。これにより、関連するサブコレクション アイテムとともにドキュメントを取得したり、異なるルート コレクション間で論理的にリンクされたデータを結合したりするなど、複雑なデータ取得パターンが可能になります。

コンセプト

このセクションでは、パイプライン オペレーションで結合を実行するためにサブクエリを使用する際の基本的なコンセプトについて説明します。

式としてのサブクエリ

サブクエリはトップレベルのステージではなく、であり、 select(...)add_fields(...)where(...)、または sort(...) など、式を受け入れる任意のステージで使用できます。

Cloud Firestore は、次の 3 種類のサブクエリをサポートしています。

  • 配列サブクエリ: サブクエリの結果セット全体をドキュメントの配列として実体化します。
  • スカラー サブクエリ: カウント、平均、関連ドキュメントの特定のフィールドなど、単一の値に評価されます。
  • subcollection(...) サブクエリ: 一対多の親子関係の結合を簡素化します。

スコープと変数

結合を記述する場合、ネストされたサブクエリは多くの場合、「外部」ドキュメント(親)のフィールドを参照する必要があります。これらのスコープをブリッジするには、 let(...) ステージ(一部の SDK では define(...) と呼ばれます)を使用して、親スコープで変数を定義します。この変数は、 サブクエリで variable(...) 関数を使用して参照できます。

構文

以降のセクションでは、結合を実行するための構文の概要について説明します。

let(...) ステージ

let(...) ステージ(一部の SDK では define(...) と呼ばれます)は、フィルタリングを行わないステージです。このステージでは、親スコープから 名前付き変数にデータを明示的に取り込み、後続のネストされたスコープで使用します。

配列サブクエリ

配列サブクエリは、サブクエリの結果セット全体を配列に実体化する式サブクエリの特殊なケースです。サブクエリがゼロ行を返す場合は、空の配列に評価されます。null 配列が返されることはありません。このようなクエリは、ネストされたコレクションや相関コレクションを実体化する場合など、最終結果に完全な結果が必要な場合に便利です。

クエリは、サブクエリでフィルタ、並べ替え、集計を行うことで、取得して返す必要のあるデータの量を減らし、クエリの費用を削減できます。サブクエリの順序が優先されます。つまり、サブクエリの sort(...) ステージは、最終的な配列の結果の順序を制御します。

toArrayExpression() SDK ラッパーを使用して、クエリを配列に変換します。

スカラー サブクエリ

スカラー サブクエリは、select(...) または where(...) ステージでよく使用されます。これにより、クエリ全体を直接実体化せずに、サブクエリの 結果をフィルタリングまたは結果として出力できます。

結果がゼロになるスカラー サブクエリは null に評価されますが、複数の要素に評価されるサブクエリはランタイム エラーになります。

スカラー サブクエリで結果ごとに 1 つのフィールドのみが生成される場合、そのフィールドはサブクエリのトップレベルの結果に昇格します。 これは、サブクエリのスキーマが単一のフィールドである select(field("user_name")) または aggregate(countAll().as("total")) でサブクエリが終了する場合に最もよく見られます。それ以外の場合、サブクエリで複数のフィールドを生成できる場合は、マップでラップされます。

toScalarExpression() SDK ラッパーを使用して、クエリをスカラー式に変換します。

subcollection(...) サブクエリ

subcollection(...) 入力ステージはステージとして提供されますが、 Cloud Firestore's の階層データモデルで結合を実行できます。階層モデルでは、クエリは多くの場合、独自のサブコレクションのデータとともにドキュメントを取得する必要があります。親参照のフィルタが続く collection_group(...)入力ステージを使用してこれを行うこともできますが、subcollection(...)を使用すると、より簡潔な構文になります。

暗黙的な結合条件を除き、ネストされたコレクションが存在しない場合でも、ドキュメントが一致しない場合は空の結果を返すという点で、配列サブクエリと同様に動作します。

これは基本的に糖衣構文です。外部スコープのドキュメントの__name__を結合キーとして自動的に使用し、階層関係を解決します。そのため、親子関係でリンクされたコレクション間でルックアップを実行する場合に推奨される方法です。

データの例

次の例では、以降のすべての例で使用するテストデータを読み込みます。

Node.js

// Load set of cities.
const cities = collection(db, "cities");

await setDoc(doc(cities, "SF"), {
  name: "San Francisco",
  state: "CA",
  country: "USA",
});
await setDoc(doc(cities, "LA"), {
  name: "Los Angeles",
  state: "CA",
  country: "USA"
});
await setDoc(doc(cities, "DC"), {
  name: "Washington, D.C.",
  state: null,
  country: "USA"
});
await setDoc(doc(cities, "TOK"), {
  name: "Tokyo",
  state: null,
  country: "Japan"
});

// Load restaurants in various cities.
const sfRestaurants = collection(db, "cities", "SF", "restaurants");
const laRestaurants = collection(db, "cities", "LA", "restaurants");
const dcRestaurants = collection(db, "cities", "DC", "restaurants");

const rest1 = await addDoc(sfRestaurants, {
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
});
const rest2 = await addDoc(sfRestaurants, {
  name: "Bay Area Burger",
  type: "burger",
  owner_id: "Sarah Jenkins"
});
const rest3 = await addDoc(sfRestaurants, {
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
});

const rest4 = await addDoc(laRestaurants, {
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
});
const rest5 = await addDoc(laRestaurants, {
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano"
});

const rest6 = await addDoc(dcRestaurants, {
  name: "Capitol Tacos",
  type: "mexican",
  owner_id: "Maria Garcia"
});
const rest7 = await addDoc(dcRestaurants, {
  name: "Georgetown Coffee",
  type: "cafe",
  owner_id: "David Kim"
});

// Load collection of reviews.
const reviews = collection(db, "reviews");

await addDoc(reviews, { restaurant: rest1, rating: 5, reviewer_id "Alice" });
await addDoc(reviews, { restaurant: rest1, rating: 4, reviewer_id "Bob" });
await addDoc(reviews, { restaurant: rest2, rating: 4, reviewer_id "Charlie" });
await addDoc(reviews, { restaurant: rest3, rating: 5, reviewer_id "Diana" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Edward" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Fiona" });
// rest4 has 0 reviews
await addDoc(reviews, { restaurant: rest5, rating: 3, reviewer_id "George" });
await addDoc(reviews, { restaurant: rest6, rating: 5, reviewer_id "Hannah" });
await addDoc(reviews, { restaurant: rest6, rating: 4, reviewer_id "Ian" });
await addDoc(reviews, { restaurant: rest7, rating: 5, reviewer_id "Julia" });

別のコレクションでドキュメントを検索する

次の reviews コレクション グループに対するクエリは、主キー参照を使用して restaurant コレクション グループを検索します。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("reviews")
  .define(field("restaurant").as("restaurant_name"))
  .addFields(db.pipeline()
    .collectionGroup("restaurant")
    .where(field("__name__").equal(variable("restaurant_name")))
    .select("name", "type")
    .toScalarExpression()
    .as("restaurant")));

レスポンス

{
  rating: 5,
  reviewer_id "Alice",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Bob",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Charlie",
  restaurant: { name: "Bay Area Burger", type: "burger" }
},
{
  rating: 5,
  reviewer_id "Diana",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Edward",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Fiona",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 3,
  reviewer_id "George",
  restaurant: { name: "Venice Pizza", type: "pizza" }
},
{
  rating: 5,
  reviewer_id "Hannah",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Ian",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 5,
  reviewer_id "Julia",
  restaurant: { name: "Georgetown Coffee", type: "cafe" }
}

複数のコレクションを結合する

次のクエリは、restaurants コレクション グループからすべてのピザ店を取得し、配列サブクエリを使用して、関連するクチコミを取得してレスポンスに直接埋め込みます。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("reviews")));

レスポンス

{
  name: "Golden Gate Pizza",
  reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  reviews: [
    { rating: 3, reviewer_id "George" }
  ]
}

複数のコレクションを集計する

次の restaurants コレクション グループに対するクエリは、相関サブクエリを使用して、reviews コレクション グループから各レストランの平均評価を取得します。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .aggregate(average("rating").as("avg_rating"))
      .toScalarExpression()
      .as("avg_rating")));

レスポンス

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

グループごとの上位 N 件(制限付きサブクエリ)

次のクエリは、restaurants コレクション グループからすべてのドキュメントを取得し、相関サブクエリを使用して、各レストランの上位 2 件の評価の高いクチコミを取得します。

これにより、クチコミの配列が大きくなりすぎず、クエリのメモリ上限に達しないようにします。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .sort(field("rating").descending())
      .limit(2)
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("top_reviews")));

レスポンス

{
  name: "Golden Gate Pizza",
  top_reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Bay Area Burger",
  top_reviews: [
    { rating: 4, reviewer_id "Charlie" }
  ]
},
{
  name: "Sunset Taco",
  top_reviews: [
    { rating: 5, reviewer_id "Diana" },
    { rating: 4, reviewer_id "Edward" }
  ]
},
{
  name: "Hollywood Sushi",
  top_reviews: []
},
{
  name: "Venice Pizza",
  top_reviews: [
    { rating: 3, reviewer_id "George" }
  ]
},
{
  name: "Capitol Tacos",
  top_reviews: [
    { rating: 5, reviewer_id "Hannah" },
    { rating: 4, reviewer_id "Ian" }
  ]
},
{
  name: "Georgetown Coffee",
  top_reviews: [
    { rating: 5, reviewer_id "Julia" }
  ]
}

サブコレクションを結合する

次のクエリは、cities コレクションをスキャンし、 subcollection(...) ステージを使用して、ネストされたコレクションのドキュメントを暗黙的に結合し、 都市ごとのレストラン数を調べます。

Node.js

let results = await execute(db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count")));

レスポンス

{
  __name__: cities/SF,
  name: "San Francisco",
  state: "CA",
  country: "USA",
  restaurant_count: 3
},
{
  __name__: cities/LA,
  name: "Los Angeles",
  state: "CA",
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/DC,
  name: "Washington, D.C.",
  state: null,
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/TOK,
  name: "Tokyo",
  state: null,
  country: "Japan",
  restaurant_count: 0
}

複数の結合条件を表現する

次のクエリは、restaurants コレクション グループをスキャンし、reviews コレクション グループと複数フィールド結合を実行して、自分のレストランをレビューしているオーナーを検索します。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("owner_id"), field("__name__"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("__name__")))
    .where(field("author").equal(variable("owner_id")))
    .aggregate(count().as("c"))
    .toScalarExpression()
    .greaterThan(0)));

レスポンス

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

アンチジョイン(NOT EXISTS

次のクエリは、restaurants コレクション グループをスキャンし、まだクチコミがないすべてのレストランを検索します。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("restaurant_name")))
    .aggregate(count().as("review_count"))
    .toScalarExpression()
    .equal(0)));

レスポンス

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

結合としてのサブクエリ

次のクエリは、各ピザ店とそのクチコミの関係をフラット化します。サブクエリを unnest(...)ステージ内に配置すると、サーバーは一致するクチコミごとに外部 レストラン ドキュメントを複製し、フラットな結合ドキュメント (SQL INNER JOINと同様)を生成します。

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .unnest(
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("review")));

レスポンス

{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
  review: { rating: 5, reviewer_id "Alice" }
},
{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi",
  review: { rating: 4, reviewer_id "Bob" }
},
{
  __name__: "cities/LA/restaurants/6CYntvNgbYzgaW652Gq1",
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  review: { rating: 3, reviewer_id "George" }
}

フィルタとしての非相関サブクエリ

次の reviews コレクションに対するクエリは、非相関サブクエリを使用してフィルタを行い、平均評価より高いクチコミを検索します。

Node.js

let results = await execute(db.pipeline()
  .collection("reviews")
  // Average review rating is 4.3
  .where(field("rating").greaterThan(db.pipeline()
    .collection("reviews")
    .aggregate(average("rating").as("avg"))
    .toScalarExpression())))
  .select("rating", "reviewer_id");

レスポンス

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

ベスト プラクティス

  • toArrayExpression() でメモリを管理する: toArrayExpression() サブクエリを使用する場合は注意が必要です。多数の ドキュメントを実体化すると、クエリのメモリ上限(128 MiB)を超える可能性があります。これを軽減するには、サブクエリ内で select(...)を使用して必要なフィールドのみを返し、 where(...) フィルタを適用して返されるドキュメントの数を制限します。必要に応じて、 limit(...) を使用して、サブクエリから返される ドキュメントの数を制限することを検討してください。
  • インデックス作成: サブクエリの where(...) 句で使用されるフィールドにインデックスが作成されていることを確認します。 パフォーマンスの高い結合は、テーブルのフルスキャンではなくインデックス シークを実行できるかどうかに依存します。

クエリのベスト プラクティスについては、クエリの最適化に関する ガイドをご覧ください。

制限事項

  • subcollection(...) スコープ: subcollection(...) 入力ステージは、階層関係を解決して結合を実行するために親 ドキュメントのコンテキストが必要なため、サブクエリ内でのみ サポートされています。
  • ネストの深さ: サブクエリは最大 20 レベルまでネストできます。
  • メモリ使用量: 実体化されたデータの 128 MiB の上限は、結合されたすべてのドキュメントを含むクエリ全体に適用されます。