概览
Firestore 企业版通过相关子查询支持关系型联接。许多 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 = db.collection("cities");
await cities.doc("SF").set({
name: "San Francisco",
state: "CA",
country: "USA",
});
await cities.doc("LA").set({
name: "Los Angeles",
state: "CA",
country: "USA"
});
await cities.doc("DC").set({
name: "Washington, D.C.",
state: null,
country: "USA"
});
await cities.doc("TOK").set({
name: "Tokyo",
state: null,
country: "Japan"
});
// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");
const rest1 = await sfRestaurants.add({
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
name: "Bay Area Burger",
type: "burger",
owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
});
const rest4 = await laRestaurants.add({
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano"
});
const rest6 = await dcRestaurants.add({
name: "Capitol Tacos",
type: "mexican",
owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
name: "Georgetown Coffee",
type: "cafe",
owner_id: "David Kim"
});
// Load collection of reviews.
const reviews = db.collection("reviews");
await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ restaurant: rest7, rating: 5, reviewer_id: "Julia" });
在另一个集合中查找文档
对 reviews 集合组执行以下查询,使用主键引用在 restaurant 集合组中执行查找。
Node.js
let results = await 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"))
.execute();
答案
{
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 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"))
.execute();
答案
{
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 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"))
.execute();
答案
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
每个群组的前 N 个(带限制的子查询)
以下查询会从 restaurants 集合组中提取所有文档,并使用相关子查询提取每家餐厅评分最高的前 2 条评价。
这可确保评价数组不会过大,从而达到查询的内存限制。
Node.js
let results = await 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"))
.execute();
答案
{
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 db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count"))
.execute();
答案
{
__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 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))
.execute();
答案
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
反联接 (NOT EXISTS)
以下查询会扫描 restaurants 集合组,并查找尚未收到任何评价的所有餐厅。
Node.js
let results = await 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))
.execute();
答案
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
子查询作为联接
以下查询会扁平化每个披萨店与其评价之间的关系。通过将子查询放置在 unnest(...) 阶段内,服务器会为每个匹配的评价复制外部餐厅文档,从而生成扁平化的联接文档(类似于 SQL INNER JOIN)。
Node.js
let results = await 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"))
.execute();
答案
{
__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 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")
.execute();
答案
{
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 限制适用于整个查询,包括所有联接的文档。