使用子查询执行联接

概览

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 限制适用于整个查询,包括所有联接的文档。