2025年9月25日: PostgreSQL 18 釋出!
支援的版本: 當前 (18) / 17 / 16 / 15 / 14 / 13
開發版本: devel
不支援的版本: 12 / 11 / 10 / 9.6 / 9.5 / 9.4 / 9.3 / 9.2 / 9.1 / 9.0 / 8.4

7.8. WITH 查詢 (公用表表達式) #

WITH 提供了一種為更復雜的查詢編寫輔助語句的方法。這些語句通常被稱為公用表表達式或CTE,可以看作是定義僅用於單個查詢的臨時表。WITH 子句中的每個輔助語句都可以是 SELECT、INSERT、UPDATE、DELETE 或 MERGE;而 WITH 子句本身則附屬於一個主要語句,該主要語句也可以是 SELECT、INSERT、UPDATE、DELETE 或 MERGE。

7.8.1. WITH 中的 SELECT #

WITH 中的 SELECT 的基本價值在於將複雜的查詢分解為更簡單的部分。例如:

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 中無法實現的功能的特性。使用 RECURSIVE,一個 WITH 查詢可以引用其自身的輸出。一個非常簡單的例子是這個將整數從 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 的輸出行中。

提示

迴圈路徑列的計算方式與上一節中顯示的深度優先排序列相同。查詢可以同時具有 SEARCH 和 CYCLE 子句,但是深度優先搜尋規範和迴圈檢測規範會產生冗餘計算,因此僅使用 CYCLE 子句並按路徑列排序更為高效。如果需要廣度優先排序,則同時指定 SEARCH 和 CYCLE 可能很有用。

測試查詢時一個有用的技巧是,如果您不確定它們是否可能迴圈,請在父查詢中放置一個 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 對每個錶行僅求值一次,而不是兩次。

上面的例子只顯示了 WITH 與 SELECT 一起使用,但它也可以以同樣的方式附加到 INSERT、UPDATE、DELETE 或 MERGE。在每種情況下,它實際上提供了可以在主命令中引用的臨時表。

7.8.4. WITH 中的資料修改語句 #

您可以在 WITH 中使用資料修改語句(INSERT、UPDATE、DELETE 或 MERGE)。這允許您在同一個查詢中執行幾個不同的操作。例如:

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 中的 DELETE 語句透過其 RETURNING 子句返回指定行,從而從 products 中刪除它們;然後主查詢讀取該輸出並將其插入到 products_log 中。

上述示例的一個細微之處在於,WITH 子句附加到 INSERT,而不是 INSERT 中的子 SELECT。這是必需的,因為資料修改語句只允許在附加到頂層語句的 WITH 子句中使用。然而,正常的 WITH 可見性規則適用,因此可以從子 SELECT 中引用 WITH 語句的輸出。

WITH 中的資料修改語句通常具有 RETURNING 子句(參見 第 6.4 節),如上面的示例所示。形成可供查詢其餘部分引用的臨時表的,是 RETURNING 子句的輸出,而不是資料修改語句的目標表。如果 WITH 中的資料修改語句缺少 RETURNING 子句,那麼它不會形成臨時表,也無法在查詢的其餘部分中引用。這樣的語句仍然會被執行。一個不特別有用的例子是:

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

此示例將從表 foo 和 bar 中刪除所有行。報告給客戶端的受影響行數將僅包括從 bar 中刪除的行。

不允許在資料修改語句的 WITH 中進行遞迴自引用。在某些情況下,可以透過引用遞迴 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 中的資料修改語句時,指定的更新實際發生的順序是不可預測的。所有語句都使用相同的快照(參見 第 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 規則。

提交更正

如果您在文件中看到任何不正確的內容、與您對特定功能的實際使用經驗不符或需要進一步澄清的內容,請使用 此表單 報告文件問題。