使用子查询执行联接

概览

Firestore 企业版支持通过相关子查询 进行关系型联接。与许多通常需要对数据进行非规范化处理或执行多个客户端请求的 NoSQL 数据库不同,子查询允许您直接在服务器上合并和汇总相关集合或子集合中的数据。

子查询是表达式,可针对外部查询处理的每个文档执行嵌套流水线。这样便可实现复杂的数据检索模式,例如提取文档及其相关子集合项,或联接不同根集合中逻辑上关联的数据。

概念

本部分介绍了在流水线操作中使用子查询执行联接的核心概念。

作为表达式的子查询

子查询不是顶级阶段,而是一个表达式,可在接受表达式的任何阶段中使用,例如 select(...)add_fields(...)where(...)sort(...)

Cloud Firestore 支持三种类型的子查询:

  • 数组子查询: 将子查询的整个结果集具体化为文档数组。
  • 标量子查询: 求值为单个值,例如计数、平均值或相关文档中的特定字段。
  • subcollection(...) 子查询: 简化了一对多父子关系的联接。

范围和变量

编写联接时,嵌套子查询通常需要引用“外部”文档(父级)中的字段。为了弥合这些范围,您可以使用 let(...)阶段(在某些 SDK 中称为 define(...))在父级范围内定义变量,然后可以使用 variable(...) 函数在 子查询中引用这些变量。

语法

以下部分简要介绍了执行联接的语法。

let(...) 阶段

The let(...) 阶段(在某些 SDK 中称为 define(...))是一个非过滤阶段,它会明确地将父级范围中的数据 引入到命名变量中,以供后续嵌套范围使用。

数组子查询

数组子查询是表达式子查询的一种特殊情况,它会将子查询的整个结果集具体化为一个数组。如果子查询返回零行,则求值为一个空数组。它永远不会返回 null 数组。当最终结果中需要完整结果时,此类查询非常有用,例如在具体化嵌套或相关集合时。

查询可以在子查询中进行过滤、排序和汇总,以减少需要提取和返回的数据量,从而帮助降低查询费用。子查询的顺序受到尊重,这意味着子查询中的 sort(...) 阶段会控制最终数组中结果的顺序。

使用 toArrayExpression() SDK 封装容器将查询转换为数组。

标量子查询

标量子查询通常在 select(...)where(...) 阶段中使用,以允许过滤或生成 子查询的结果,而无需直接具体化完整查询。

生成零结果的标量子查询本身将求值为 null,而求值为多个元素的子查询将导致运行时错误。

当标量子查询为每个结果仅生成一个字段时,该字段会“提升”为子查询的顶级结果。 当子查询以 select(field("user_name"))aggregate(countAll().as("total")) 结尾时,这种情况最为常见,因为子查询的架构只是一个 字段。否则,当子查询可以生成多个字段时,这些字段会封装在映射中。

使用 toScalarExpression() SDK 封装容器将查询转换为标量表达式。

subcollection(...) 子查询

虽然作为阶段提供,但 subcollection(...)输入阶段允许 对 Cloud Firestore's 的分层数据模型执行联接。在分层模型中,查询通常需要检索文档及其自己的子集合中的数据。虽然您可以使用 collection_group(...)输入阶段,然后对父级引用进行过滤来实现此目的,但subcollection(...)提供了更简洁的语法。

除了隐式联接条件之外,它的行为与数组子查询类似,如果没有任何文档匹配,则返回空结果,即使嵌套集合不存在也是如此。

它从根本上来说是语法糖:它会自动使用__name__外部范围中文档的 `__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
}

每个组的 Top-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"
}

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