使用子查詢執行聯結

總覽

Firestore Enterprise 版支援透過關聯子查詢進行關聯式彙整。許多 NoSQL 資料庫通常需要反正規化資料,或執行多個用戶端要求,但子查詢可讓您直接在伺服器上,合併及匯總相關集合或子集合的資料。

子查詢是運算式,會針對外部查詢處理的每份文件,執行巢狀管道。這可實現複雜的資料擷取模式,例如擷取文件及其相關子集合項目,或彙整不同根集合中邏輯連結的資料。

概念

本節介紹在管道作業中使用子查詢執行 join 的核心概念。

子查詢做為運算式

子查詢並非頂層階段,而是運算式,可用於任何接受運算式的階段,例如 select(...)、add_fields(...)、where(...) 或 sort(...)。

Cloud Firestore 支援三種子查詢:

  • 陣列子查詢:將子查詢的整個結果集具體化為文件陣列。
  • 純量子查詢:評估為單一值,例如計數、平均值或相關文件中的特定欄位。
  • subcollection(...) 子查詢:簡化一對多父項與子項關係的彙整作業。

範圍和變數

編寫 join 時,巢狀子查詢通常需要參照「外部」文件 (父項) 的欄位。如要橋接這些範圍,請使用 let(...) 階段 (在某些 SDK 中稱為 define(...)) 定義父項範圍中的變數,然後使用 variable(...) 函式在子查詢中參照這些變數。

語法

以下各節將概略說明執行 join 的語法。

let(...) 階段

let(...) 階段 (在某些 SDK 中稱為 define(...)) 是非篩選階段,會明確將父項範圍中的資料帶入具名變數,以便在後續巢狀範圍中使用。

陣列子查詢

陣列子查詢是運算式子查詢的特殊案例,會將子查詢的整個結果集具體化為陣列。如果子查詢傳回零列,則會評估為空陣列。絕不會傳回 null 陣列。如果最終結果需要完整結果,例如具體化巢狀或相關集合,這類查詢就非常實用。

查詢可以在子查詢中篩選、排序及匯總,減少需要擷取及傳回的資料量,進而降低查詢成本。系統會遵循子查詢的順序,也就是說,子查詢中的 sort(...) 階段會控制最終陣列中的結果順序。

使用 toArrayExpression() SDK 包裝函式,將查詢轉換為陣列。

純量子查詢

純量子查詢通常用於 select(...) 或 where(...) 階段,因為這類子查詢可篩選或產生子查詢結果,而不必直接具體化完整查詢。

如果純量子查詢產生零個結果,則會評估為 null 本身,而評估為多個元素的子查詢則會導致執行階段錯誤。

當純量子查詢每個結果只產生一個欄位時,該欄位會提升為子查詢的頂層結果。如果子查詢的結構定義只有單一欄位,且子查詢結尾為 select(field("user_name")) 或 aggregate(countAll().as("total")),就最常發生這種情況。否則,當子查詢可以產生多個欄位時,這些欄位會包裝在對應中。

使用 toScalarExpression() SDK 包裝函式,將查詢轉換為純量運算式。

subcollection(...) 個子查詢

雖然 subcollection(...) 輸入階段是以階段形式提供,但可對 Cloud Firestore 的階層式資料模型執行彙整。在階層模型中,查詢通常需要擷取文件,以及其子集合中的資料。雖然您可以使用 collection_group(...) 輸入階段,然後在父項參照上使用篩選器來達成此目的,但 subcollection(...) 提供了更簡潔的語法。

除了隱含的 join 條件外,這項作業的運作方式與陣列子查詢類似,如果沒有相符的文件,即使巢狀集合不存在,也會傳回空白結果。

基本上,這是一種語法糖:它會自動使用外部範圍中文件的 __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"
}

反向 Join (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"
}

子查詢做為 JOIN

下列查詢會將每間披薩店與其評論之間的關係扁平化。將子查詢放在 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(...) 子句中使用的欄位已建立索引。高效能 join 仰賴執行索引搜尋的能力,而非完整資料表掃描。

如需更多查詢最佳做法,請參閱查詢最佳化指南。

限制

  • subcollection(...) 範圍:subcollection(...) 輸入階段僅支援子查詢,因為需要父項文件的背景資訊,才能解析階層關係並執行彙整。
  • 巢狀深度:子查詢最多可巢狀 20 層。
  • 記憶體用量:具體化資料的 128 MiB 限制適用於整個查詢,包括所有彙整的文件。