使用子查詢執行聯結

總覽

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

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

概念

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

以運算式形式呈現的子查詢

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

Cloud Firestore 支援三種子查詢:

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

範圍和變數

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

語法

以下各節將概述執行聯結的語法。

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(...) 提供的語法簡潔許多。

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

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

Anti-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"
}

子查詢做為聯結

下列查詢會將每個披薩店及其評論之間的關係扁平化。將子查詢放在 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 限制適用於整個查詢,包括所有已聯結的文件。