總覽
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 限制適用於整個查詢,包括所有彙整的文件。