Avoid rewriting data-modifying CTEs more than once.
authorDean Rasheed <[email protected]>
Sat, 29 Nov 2025 12:34:45 +0000 (12:34 +0000)
committerDean Rasheed <[email protected]>
Sat, 29 Nov 2025 12:34:45 +0000 (12:34 +0000)
Formerly, when updating an auto-updatable view, or a relation with
rules, if the original query had any data-modifying CTEs, the rewriter
would rewrite those CTEs multiple times as RewriteQuery() recursed
into the product queries. In most cases that was harmless, because
RewriteQuery() is mostly idempotent. However, if the CTE involved
updating an always-generated column, it would trigger an error because
any subsequent rewrite would appear to be attempting to assign a
non-default value to the always-generated column.

This could perhaps be fixed by attempting to make RewriteQuery() fully
idempotent, but that looks quite tricky to achieve, and would probably
be quite fragile, given that more generated-column-type features might
be added in the future.

Instead, fix by arranging for RewriteQuery() to rewrite each CTE
exactly once (by tracking the number of CTEs already rewritten as it
recurses). This has the advantage of being simpler and more efficient,
but it does make RewriteQuery() dependent on the order in which
rewriteRuleAction() joins the CTE lists from the original query and
the rule action, so care must be taken if that is ever changed.

Reported-by: Bernice Southey <[email protected]>
Author: Bernice Southey <[email protected]>
Author: Dean Rasheed <[email protected]>
Reviewed-by: Tom Lane <[email protected]>
Reviewed-by: Kirill Reshke <[email protected]>
Discussion: https://postgr.es/m/CAEDh4nyD6MSH9bROhsOsuTqGAv_QceU_GDvN9WcHLtZTCYM1kA@mail.gmail.com
Backpatch-through: 14

src/backend/rewrite/rewriteHandler.c
src/test/regress/expected/with.out
src/test/regress/sql/with.sql

index bbec678daa605d09f7620a3cec5aa6f5a7149589..5e154f1ae9b6d75281c44cf08bfcb0adea191fc9 100644 (file)
@@ -585,7 +585,10 @@ rewriteRuleAction(Query *parsetree,
            }
        }
 
-       /* OK, it's safe to combine the CTE lists */
+       /*
+        * OK, it's safe to combine the CTE lists.  Beware that RewriteQuery
+        * knows we concatenate the lists in this order.
+        */
        sub_action->cteList = list_concat(sub_action->cteList,
                                          copyObject(parsetree->cteList));
        /* ... and don't forget about the associated flags */
@@ -3677,9 +3680,13 @@ rewriteTargetView(Query *parsetree, Relation view)
  * orig_rt_length is the length of the originating query's rtable, for product
  * queries created by fireRules(), and 0 otherwise.  This is used to skip any
  * already-processed VALUES RTEs from the original query.
+ *
+ * num_ctes_processed is the number of CTEs at the end of the query's cteList
+ * that have already been rewritten, and must not be rewritten again.
  */
 static List *
-RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length)
+RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
+            int num_ctes_processed)
 {
    CmdType     event = parsetree->commandType;
    bool        instead = false;
@@ -3693,17 +3700,29 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length)
     * First, recursively process any insert/update/delete statements in WITH
     * clauses.  (We have to do this first because the WITH clauses may get
     * copied into rule actions below.)
+    *
+    * Any new WITH clauses from rule actions are processed when we recurse
+    * into product queries below.  However, when recursing, we must take care
+    * to avoid rewriting a CTE query more than once (because expanding
+    * generated columns in the targetlist more than once would fail).  Since
+    * new CTEs from product queries are added to the start of the list (see
+    * rewriteRuleAction), we just skip the last num_ctes_processed items.
     */
    foreach(lc1, parsetree->cteList)
    {
        CommonTableExpr *cte = lfirst_node(CommonTableExpr, lc1);
        Query      *ctequery = castNode(Query, cte->ctequery);
+       int         i = foreach_current_index(lc1);
        List       *newstuff;
 
+       /* Skip already-processed CTEs at the end of the list */
+       if (i >= list_length(parsetree->cteList) - num_ctes_processed)
+           break;
+
        if (ctequery->commandType == CMD_SELECT)
            continue;
 
-       newstuff = RewriteQuery(ctequery, rewrite_events, 0);
+       newstuff = RewriteQuery(ctequery, rewrite_events, 0, 0);
 
        /*
         * Currently we can only handle unconditional, single-statement DO
@@ -3762,6 +3781,7 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length)
                     errmsg("multi-statement DO INSTEAD rules are not supported for data-modifying statements in WITH")));
        }
    }
+   num_ctes_processed = list_length(parsetree->cteList);
 
    /*
     * If the statement is an insert, update, or delete, adjust its targetlist
@@ -4087,7 +4107,8 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length)
                newstuff = RewriteQuery(pt, rewrite_events,
                                        pt == parsetree ?
                                        orig_rt_length :
-                                       product_orig_rt_length);
+                                       product_orig_rt_length,
+                                       num_ctes_processed);
                rewritten = list_concat(rewritten, newstuff);
            }
 
@@ -4239,7 +4260,7 @@ QueryRewrite(Query *parsetree)
     *
     * Apply all non-SELECT rules possibly getting 0 or many queries
     */
-   querylist = RewriteQuery(parsetree, NIL, 0);
+   querylist = RewriteQuery(parsetree, NIL, 0, 0);
 
    /*
     * Step 2
index 8ce2b173fd4977c40b9a7efbd270ad97aef54977..bc5bb2c39945631c0832e3f8fb6c42b33bb75521 100644 (file)
@@ -2767,6 +2767,47 @@ SELECT * FROM bug6051_3;
 ---
 (0 rows)
 
+-- check that recursive CTE processing doesn't rewrite a CTE more than once
+-- (must not try to expand GENERATED ALWAYS IDENTITY columns more than once)
+CREATE TEMP TABLE id_alw1 (i int GENERATED ALWAYS AS IDENTITY);
+CREATE TEMP TABLE id_alw2 (i int GENERATED ALWAYS AS IDENTITY);
+CREATE TEMP VIEW id_alw2_view AS SELECT * FROM id_alw2;
+CREATE TEMP TABLE id_alw3 (i int GENERATED ALWAYS AS IDENTITY);
+CREATE RULE id_alw3_ins AS ON INSERT TO id_alw3 DO INSTEAD
+  WITH t1 AS (INSERT INTO id_alw1 DEFAULT VALUES RETURNING i)
+    INSERT INTO id_alw2_view DEFAULT VALUES RETURNING i;
+CREATE TEMP VIEW id_alw3_view AS SELECT * FROM id_alw3;
+CREATE TEMP TABLE id_alw4 (i int GENERATED ALWAYS AS IDENTITY);
+WITH t4 AS (INSERT INTO id_alw4 DEFAULT VALUES RETURNING i)
+  INSERT INTO id_alw3_view DEFAULT VALUES RETURNING i;
+ i 
+---
+ 1
+(1 row)
+
+SELECT * from id_alw1;
+ i 
+---
+ 1
+(1 row)
+
+SELECT * from id_alw2;
+ i 
+---
+ 1
+(1 row)
+
+SELECT * from id_alw3;
+ i 
+---
+(0 rows)
+
+SELECT * from id_alw4;
+ i 
+---
+ 1
+(1 row)
+
 -- check case where CTE reference is removed due to optimization
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT q1 FROM
index ed1b38c1f3c08b114264d2aa3d5c592ed609aa7b..cf40468debabf859f05d6f7d39e02ab70b0ba428 100644 (file)
@@ -1319,6 +1319,29 @@ COMMIT;
 
 SELECT * FROM bug6051_3;
 
+-- check that recursive CTE processing doesn't rewrite a CTE more than once
+-- (must not try to expand GENERATED ALWAYS IDENTITY columns more than once)
+CREATE TEMP TABLE id_alw1 (i int GENERATED ALWAYS AS IDENTITY);
+
+CREATE TEMP TABLE id_alw2 (i int GENERATED ALWAYS AS IDENTITY);
+CREATE TEMP VIEW id_alw2_view AS SELECT * FROM id_alw2;
+
+CREATE TEMP TABLE id_alw3 (i int GENERATED ALWAYS AS IDENTITY);
+CREATE RULE id_alw3_ins AS ON INSERT TO id_alw3 DO INSTEAD
+  WITH t1 AS (INSERT INTO id_alw1 DEFAULT VALUES RETURNING i)
+    INSERT INTO id_alw2_view DEFAULT VALUES RETURNING i;
+CREATE TEMP VIEW id_alw3_view AS SELECT * FROM id_alw3;
+
+CREATE TEMP TABLE id_alw4 (i int GENERATED ALWAYS AS IDENTITY);
+
+WITH t4 AS (INSERT INTO id_alw4 DEFAULT VALUES RETURNING i)
+  INSERT INTO id_alw3_view DEFAULT VALUES RETURNING i;
+
+SELECT * from id_alw1;
+SELECT * from id_alw2;
+SELECT * from id_alw3;
+SELECT * from id_alw4;
+
 -- check case where CTE reference is removed due to optimization
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT q1 FROM