2024 年 9 月 26 日: PostgreSQL 17 发布!
支持版本:当前 (17) / 16 / 15 / 14 / 13 / 12
开发版本:devel
不支持版本:11 / 10 / 9.6 / 9.5 / 9.4 / 9.3 / 9.2 / 9.1 / 9.0 / 8.4

7.8. WITH 查询(公用表表达式) #

WITH 提供了一种方法来编写辅助语句,以便在更大的查询中使用。这些语句通常被称为公用表表达式或CTE,可以被认为是定义只为一个查询存在的临时表。WITH 子句中的每个辅助语句可以是 SELECTINSERTUPDATEDELETEMERGE;而 WITH 子句本身附加到一个主语句,该主语句也可以是 SELECTINSERTUPDATEDELETEMERGE

7.8.1. SELECTWITH#

SELECTWITH 中的基本价值在于将复杂的查询分解成更简单的部分。一个例子是

WITH regional_sales AS (
    SELECT region, SUM(amount) AS total_sales
    FROM orders
    GROUP BY region
), top_regions AS (
    SELECT region
    FROM regional_sales
    WHERE total_sales > (SELECT SUM(total_sales)/10 FROM regional_sales)
)
SELECT region,
       product,
       SUM(quantity) AS product_units,
       SUM(amount) AS product_sales
FROM orders
WHERE region IN (SELECT region FROM top_regions)
GROUP BY region, product;

它仅显示在顶级销售区域中的每个产品的销售总额。WITH 子句定义了两个名为 regional_salestop_regions 的辅助语句,其中 regional_sales 的输出用于 top_regions,而 top_regions 的输出用于主 SELECT 查询。这个例子可以在没有 WITH 的情况下编写,但我们需要两层嵌套的子 SELECT。这样更容易理解。

7.8.2. 递归查询 #

可选的 RECURSIVE 修饰符将 WITH 从一个简单的语法便利变成了能够完成标准 SQL 中无法实现的功能的特性。使用 RECURSIVEWITH 查询可以引用其自身的输出。一个非常简单的例子是这个查询,用于将 1 到 100 的整数求和

WITH RECURSIVE t(n) AS (
    VALUES (1)
  UNION ALL
    SELECT n+1 FROM t WHERE n < 100
)
SELECT sum(n) FROM t;

递归 WITH 查询的一般形式始终是 非递归项,然后是 UNION(或 UNION ALL),然后是 递归项,其中只有递归项可以包含对查询自身输出的引用。这样的查询按如下方式执行

递归查询评估

  1. 评估非递归项。对于 UNION(但不包括 UNION ALL),丢弃重复行。将所有剩余的行包含在递归查询的结果中,并将它们放在一个临时的 工作表中。

  2. 只要工作表不为空,就重复以下步骤

    1. 评估递归项,将工作表的当前内容替换为递归自引用。对于 UNION(但不包括 UNION ALL),丢弃重复行和与任何先前结果行重复的行。将所有剩余的行包含在递归查询的结果中,并将它们放在一个临时的 中间表中。

    2. 将工作表的内容替换为中间表的内容,然后清空中间表。

注意

虽然 RECURSIVE 允许递归地指定查询,但在内部,这样的查询是迭代评估的。

在上面的例子中,工作表在每一步只有一个行,它在连续的步骤中取值从 1 到 100。在第 100 步,由于 WHERE 子句,没有输出,因此查询终止。

递归查询通常用于处理层次结构或树状数据。一个有用的例子是这个查询,用于查找给定产品的所有直接和间接子部件,假设只给定一个显示直接包含关系的表

WITH RECURSIVE included_parts(sub_part, part, quantity) AS (
    SELECT sub_part, part, quantity FROM parts WHERE part = 'our_product'
  UNION ALL
    SELECT p.sub_part, p.part, p.quantity * pr.quantity
    FROM included_parts pr, parts p
    WHERE p.part = pr.sub_part
)
SELECT sub_part, SUM(quantity) as total_quantity
FROM included_parts
GROUP BY sub_part

7.8.2.2. 循环检测 #

在处理递归查询时,重要的是确保查询的递归部分最终不会返回任何元组,否则查询将无限循环。有时,使用 UNION 而不是 UNION ALL 可以通过丢弃与先前输出行重复的行来实现这一点。但是,循环并不总是涉及完全重复的输出行:可能只需要检查一个或几个字段,以查看是否之前已经到达相同的点。处理这种情况的标准方法是计算一个已经访问值的数组。例如,再次考虑以下使用 link 字段搜索表 graph 的查询

WITH RECURSIVE search_graph(id, link, data, depth) AS (
    SELECT g.id, g.link, g.data, 0
    FROM graph g
  UNION ALL
    SELECT g.id, g.link, g.data, sg.depth + 1
    FROM graph g, search_graph sg
    WHERE g.id = sg.link
)
SELECT * FROM search_graph;

如果 link 关系包含循环,则此查询将循环。由于我们需要一个 深度 输出,仅仅将 UNION ALL 更改为 UNION 不会消除循环。相反,我们需要识别在遵循特定的链接路径时是否再次到达了同一行。我们在易于循环的查询中添加两列 is_cyclepath

WITH RECURSIVE search_graph(id, link, data, depth, is_cycle, path) AS (
    SELECT g.id, g.link, g.data, 0,
      false,
      ARRAY[g.id]
    FROM graph g
  UNION ALL
    SELECT g.id, g.link, g.data, sg.depth + 1,
      g.id = ANY(path),
      path || g.id
    FROM graph g, search_graph sg
    WHERE g.id = sg.link AND NOT is_cycle
)
SELECT * FROM search_graph;

除了防止循环之外,数组值本身通常很有用,因为它表示到达任何特定行的 路径

在需要多个字段来识别循环的一般情况下,使用一个行的数组。例如,如果我们需要比较字段 f1f2

WITH RECURSIVE search_graph(id, link, data, depth, is_cycle, path) AS (
    SELECT g.id, g.link, g.data, 0,
      false,
      ARRAY[ROW(g.f1, g.f2)]
    FROM graph g
  UNION ALL
    SELECT g.id, g.link, g.data, sg.depth + 1,
      ROW(g.f1, g.f2) = ANY(path),
      path || ROW(g.f1, g.f2)
    FROM graph g, search_graph sg
    WHERE g.id = sg.link AND NOT is_cycle
)
SELECT * FROM search_graph;

提示

在只需要一个字段来识别循环的常见情况下,省略 ROW() 语法。这允许使用一个简单的数组而不是一个复合类型的数组,从而提高效率。

存在内置语法来简化循环检测。上面的查询也可以这样写

WITH RECURSIVE search_graph(id, link, data, depth) AS (
    SELECT g.id, g.link, g.data, 1
    FROM graph g
  UNION ALL
    SELECT g.id, g.link, g.data, sg.depth + 1
    FROM graph g, search_graph sg
    WHERE g.id = sg.link
) CYCLE id SET is_cycle USING path
SELECT * FROM search_graph;

它将在内部重写为上面的形式。CYCLE 子句首先指定要跟踪用于循环检测的列列表,然后指定一个将显示是否检测到循环的列名,最后指定另一个将跟踪路径的列名。循环列和路径列将隐式地添加到 CTE 的输出行中。

提示

循环路径列的计算方式与上一节中显示的深度优先排序列相同。一个查询可以同时具有 SEARCHCYCLE 子句,但深度优先搜索规范和循环检测规范会创建冗余计算,因此只使用 CYCLE 子句并按路径列排序更高效。如果需要广度优先排序,则指定 SEARCHCYCLE 都很有用。

在测试查询时,如果你不确定查询是否会循环,一个有用的技巧是在父查询中放置一个 LIMIT。例如,这个查询如果没有 LIMIT,将会无限循环

WITH RECURSIVE t(n) AS (
    SELECT 1
  UNION ALL
    SELECT n+1 FROM t
)
SELECT n FROM t LIMIT 100;

这是有效的,因为 PostgreSQL 的实现仅评估 WITH 查询中实际被父查询获取的那么多行。不建议在生产环境中使用此技巧,因为其他系统可能会有不同的工作方式。此外,如果您让外部查询对递归查询的结果进行排序或将其与其他表联接,它通常不起作用,因为在这种情况下,外部查询通常会尝试获取 WITH 查询的全部输出。

7.8.3. 公共表表达式物化 #

WITH 查询的一个有用属性是,它们通常在父查询的每次执行中只评估一次,即使父查询或同级 WITH 查询多次引用它们。因此,在多个地方需要的昂贵计算可以放在 WITH 查询中以避免重复工作。另一个可能的应用是防止意外地多次评估具有副作用的函数。然而,另一方面是优化器无法将父查询的限制推入多次引用的 WITH 查询中,因为这可能会影响 WITH 查询输出的所有使用,而它应该只影响其中一个。多次引用的 WITH 查询将按原样进行评估,不会抑制父查询可能随后丢弃的行。(但是,如上所述,如果对查询的引用需要有限数量的行,评估可能会提前停止。)

但是,如果 WITH 查询是非递归且无副作用的(即,它是一个 SELECT 不包含易变函数),那么它可以折叠到父查询中,从而允许对这两个查询级别进行联合优化。默认情况下,如果父查询只引用 WITH 查询一次,则会发生这种情况,但如果它引用 WITH 查询多次,则不会发生这种情况。您可以通过指定 MATERIALIZED 来强制单独计算 WITH 查询,或者通过指定 NOT MATERIALIZED 来强制将其合并到父查询中,来覆盖该决定。后一种选择可能会导致 WITH 查询的重复计算,但如果 WITH 查询的每次使用只需要 WITH 查询的完整输出的一小部分,它仍然可以节省大量开销。

这些规则的一个简单示例是

WITH w AS (
    SELECT * FROM big_table
)
SELECT * FROM w WHERE key = 123;

WITH 查询将被折叠,产生与以下相同的执行计划

SELECT * FROM big_table WHERE key = 123;

特别是,如果 key 上有一个索引,它可能会被用来只获取具有 key = 123 的行。另一方面,在

WITH w AS (
    SELECT * FROM big_table
)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.key = w2.ref
WHERE w2.key = 123;

WITH 查询将被物化,产生 big_table 的临时副本,然后该副本与自身连接 - 没有任何索引的帮助。如果将此查询写为

WITH w AS NOT MATERIALIZED (
    SELECT * FROM big_table
)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.key = w2.ref
WHERE w2.key = 123;

这样,父查询的限制就可以直接应用于 big_table 的扫描。

NOT MATERIALIZED 可能不可取的一个例子是

WITH w AS (
    SELECT key, very_expensive_function(val) as f FROM some_table
)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.f = w2.f;

在这里,WITH 查询的物化确保 very_expensive_function 仅对每个表行评估一次,而不是两次。

上面的示例只显示了 WITHSELECT 一起使用,但它可以以相同的方式附加到 INSERTUPDATEDELETEMERGE。在每种情况下,它实际上提供了可以在主命令中引用的临时表。

7.8.4. WITH 中的数据修改语句 #

您可以在 WITH 中使用数据修改语句(INSERTUPDATEDELETEMERGE)。这使您能够在同一个查询中执行多个不同的操作。例如

WITH moved_rows AS (
    DELETE FROM products
    WHERE
        "date" >= '2010-10-01' AND
        "date" < '2010-11-01'
    RETURNING *
)
INSERT INTO products_log
SELECT * FROM moved_rows;

此查询实际上将 products 中的行移动到 products_log 中。WITH 中的 DELETEproducts 中删除指定行,通过其 RETURNING 子句返回其内容;然后,主查询读取该输出并将它插入到 products_log 中。

上面的例子一个细微之处是,WITH 子句附加到 INSERT,而不是附加到 INSERT 中的子 SELECT。这是必要的,因为数据修改语句只能在附加到顶层语句的 WITH 子句中使用。但是,正常的 WITH 可见性规则适用,因此可以从子 SELECT 中引用 WITH 语句的输出。

WITH 中的数据修改语句通常具有 RETURNING 子句(参见 Section 6.4),如上面的示例所示。它是 RETURNING 子句的输出,不是 数据修改语句的目标表,它形成了可以由查询的其余部分引用的临时表。如果 WITH 中的数据修改语句缺少 RETURNING 子句,则它不形成任何临时表,并且不能在查询的其余部分中引用。但是,这样的语句将被执行。一个不太有用的例子是

WITH t AS (
    DELETE FROM foo
)
DELETE FROM bar;

此示例将从表 foobar 中删除所有行。报告给客户端的受影响行数仅包括从 bar 中删除的行。

数据修改语句中的递归自引用是不允许的。在某些情况下,可以通过引用递归 WITH 的输出来解决此限制,例如

WITH RECURSIVE included_parts(sub_part, part) AS (
    SELECT sub_part, part FROM parts WHERE part = 'our_product'
  UNION ALL
    SELECT p.sub_part, p.part
    FROM included_parts pr, parts p
    WHERE p.part = pr.sub_part
)
DELETE FROM parts
  WHERE part IN (SELECT part FROM included_parts);

此查询将删除产品的全部直接和间接子部分。

WITH 中的数据修改语句只执行一次,并且始终完成,无论主查询是否读取其全部(或实际上任何)输出。请注意,这与 WITH 中的 SELECT 规则不同:如上一节所述,SELECT 的执行仅进行到主查询需要其输出为止。

WITH 中的子语句是与彼此以及与主查询并发执行的。因此,在 WITH 中使用数据修改语句时,指定更新实际发生的顺序是不可预测的。所有语句都使用相同的 快照(参见 Chapter 13)执行,因此它们无法 看到 彼此对目标表的影响。这减轻了实际行更新顺序不可预测性的影响,意味着 RETURNING 数据是不同 WITH 子语句和主查询之间通信更改的唯一方式。例如,在

WITH t AS (
    UPDATE products SET price = price * 1.05
    RETURNING *
)
SELECT * FROM products;

外部 SELECT 将返回 UPDATE 操作之前的原始价格,而在

WITH t AS (
    UPDATE products SET price = price * 1.05
    RETURNING *
)
SELECT * FROM t;

外部 SELECT 将返回更新后的数据。

不支持在单个语句中尝试两次更新同一行。只进行其中一项修改,但很难(有时甚至不可能)可靠地预测哪一项。这也适用于删除同一个语句中已经更新的行:只执行更新。因此,您通常应该避免在单个语句中尝试两次修改同一行。特别是,避免编写可能影响与主语句或同级子语句相同的行的 WITH 子语句。此类语句的效果是不可预测的。

目前,任何用作 WITH 中的数据修改语句的目标的表都不能具有条件规则、ALSO 规则或扩展到多个语句的 INSTEAD 规则。

提交更正

如果您在文档中看到任何不正确的内容、与您对特定功能的体验不符或需要进一步说明,请使用 此表格 报告文档问题。