使用子查询执行联接

概览

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 = 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 个(带 Limit 的子查询)

以下查询从 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"
}

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