agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
177+ messages / 2 participants
[nested] [flat]

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-16 23:28 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-16 23:28 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++++++
 src/backend/commands/tablecmds.c          |  46 ++++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 155 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      |  81 +++++++++++
 5 files changed, 339 insertions(+), 20 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..8f207c4a79d 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = (Form_pg_constraint) GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = (Var *) linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e7386e81b07..005fbf78fbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8863,6 +8863,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8926,29 +8927,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 75f64628aef..4062f985c5a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,157 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4980,8 +5131,10 @@ alter table testgen.tpart alter column b add generated always as (a * 2) stored;
 ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
 DETAIL:  This operation is not supported for partitioned tables.
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
+NOTICE:  drop cascades to 6 other objects
 DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
 drop cascades to table testgen.t2
 drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d776595a6ed..821f04e8073 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3197,6 +3197,87 @@ alter table testgen.t3 alter column b add generated always as (a * 2) stored;
 select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.51.2


--24dzbv6kpqxe4yje--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-03-29 19:45 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-03-29 19:45 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 66622bf4837..1d98287af62 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8911,6 +8911,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -8974,29 +8975,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2567d918ec3..b3ee82f401e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4943,6 +4943,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4986,6 +5213,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--tyerjrpxgsfvwown--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-04-24 08:44 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-04-24 08:44 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  75 +++++++
 src/backend/commands/tablecmds.c          |  46 +++--
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 232 +++++++++++++++++++++-
 src/test/regress/sql/alter_table.sql      | 147 ++++++++++++++
 5 files changed, 481 insertions(+), 21 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..cfb0a0068cf 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
 #include "access/sysattr.h"
 #include "access/table.h"
 #include "catalog/catalog.h"
@@ -29,6 +30,8 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
+#include "parser/parse_relation.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +697,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa54c629f8b..c886e6e5ca7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8938,6 +8938,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	NewColumnValue *newval;
 	RawColumnDefault *rawEnt;
 	Relation	pg_attribute;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9001,29 +9002,36 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	/* Make above changes visible */
 	CommandCounterIncrement();
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
-
-	/* Make above changes visible */
-	CommandCounterIncrement();
-
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
-
 	/* Build a concrete expression for the new default (generated) value */
 	defval = (Expr *) build_column_default(rel, attnum);
 	defval = expression_planner(defval);
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = defval;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   (Node *) defval);
+
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
+
+		/* Make above changes visible */
+		CommandCounterIncrement();
+
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = defval;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 08981f4e380..5346791fd8d 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4955,6 +4955,233 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
  t
 (1 row)
 
+drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+ a | b | expected | correct 
+---+---+----------+---------
+ 1 | 2 |        2 | t
+ 2 | 4 |        4 | t
+(2 rows)
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+ a | b  | expected | correct 
+---+----+----------+---------
+ 3 |  6 |        6 | t
+ 4 |  8 |        8 | t
+ 5 | 10 |       10 | t
+(3 rows)
+
+rollback;
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+ERROR:  cannot change inherited column to be a stored generated column
+rollback;
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+ERROR:  ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
@@ -4998,6 +5225,7 @@ alter table testgen.t3 alter column b
 ERROR:  cannot use subquery in column generation expression
 drop table testgen.t3;
 drop schema testgen cascade;
-NOTICE:  drop cascades to 4 other objects
-DETAIL:  drop cascades to table testgen.t3
+NOTICE:  drop cascades to 3 other objects
+DETAIL:  drop cascades to table testgen.t5
+drop cascades to function testgen.gen()
 drop cascades to table testgen.t1
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 76187083289..efb08b9ff70 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,153 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b) select x, x from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+  add generated always as (a * 2) stored;
+-- expected: all the partitions have been rewritten
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p1 order by a;
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.tpart_p2 order by a;
+rollback;
+
+-- altering a single partition is not allowed
+begin;
+-- expected: error
+alter table testgen.tpart_p1 alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+    add generated always as (a * 2) stored;
+rollback;
+
+drop table testgen.tpart;
+
+-- subpartitions
+create table testgen.tpart (a int, b int, c int)
+  partition by hash (a);
+create table testgen.tpart_p1 partition of testgen.tpart
+  for values with (modulus 2, remainder 0)
+  partition by hash (b);
+create table testgen.tpart_p1_1 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p1_2 partition of testgen.tpart_p1
+  for values with (modulus 2, remainder 1);
+create table testgen.tpart_p2 partition of testgen.tpart
+  for values with (modulus 2, remainder 1)
+  partition by hash (b);
+create table testgen.tpart_p2_1 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 0);
+create table testgen.tpart_p2_2 partition of testgen.tpart_p2
+  for values with (modulus 2, remainder 1);
+insert into testgen.tpart (a, b)
+  select x, y
+    from generate_series(1, 5) x
+    cross join generate_series(1, 5) y;
+-- currently, it is not possible to change the generated state of an
+-- inheritance tree of depth >= 2 (same as in DROP EXPRESSION), so we expect an
+-- error here. This might be fixed later.
+begin;
+alter table testgen.tpart alter column c
+    add generated always as (a + b) stored;
+rollback;
+
+drop table testgen.tpart;
+
 -- tests for invalid invocations
 alter table doesnotexist alter column foo
   add generated always as (bar * 2) stored;
-- 
2.47.0


--4wx636ozsu2eakgd--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v21 2/3] Lock referenced objects before permission checks in DDL commands
@ 2026-04-27 14:19 Bertrand Drouvot <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Bertrand Drouvot @ 2026-04-27 14:19 UTC (permalink / raw)

Add LockNotPinnedObjectById() calls before object_aclcheck() at all caller
sites where a permission check on a referenced object occurs before the
dependency on that object is recorded. This converts permission check before
lock to lock before permission check. It closes the TOCTOU window that could
allow a REVOKE to succeed between the permission check and the dependency
recording.

Author: Bertrand Drouvot <[email protected]>
Reviewed-by:
Discussion: https://postgr.es/m/[email protected]
---
 src/backend/catalog/dependency.c        | 22 ++++++++++++++++++++++
 src/backend/catalog/namespace.c         |  1 +
 src/backend/catalog/pg_aggregate.c      |  5 +++++
 src/backend/catalog/pg_cast.c           |  1 +
 src/backend/catalog/pg_operator.c       |  2 ++
 src/backend/commands/aggregatecmds.c    |  2 ++
 src/backend/commands/alter.c            |  3 +++
 src/backend/commands/collationcmds.c    |  2 ++
 src/backend/commands/conversioncmds.c   |  3 +++
 src/backend/commands/extension.c        |  1 +
 src/backend/commands/foreigncmds.c      |  5 +++++
 src/backend/commands/functioncmds.c     | 11 +++++++++++
 src/backend/commands/indexcmds.c        |  2 ++
 src/backend/commands/opclasscmds.c      |  2 ++
 src/backend/commands/operatorcmds.c     |  8 ++++++++
 src/backend/commands/statscmds.c        |  1 +
 src/backend/commands/subscriptioncmds.c |  4 ++++
 src/backend/commands/tablecmds.c        |  7 +++++++
 src/backend/commands/trigger.c          |  2 ++
 src/backend/commands/tsearchcmds.c      |  2 ++
 src/backend/commands/typecmds.c         |  9 +++++++++
 src/include/catalog/dependency.h        |  1 +
 22 files changed, 96 insertions(+)
  22.5% src/backend/catalog/
  76.2% src/backend/commands/

diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index 7e26691393d..a41e6ee2175 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -1678,6 +1678,24 @@ LockNotPinnedObject(const ObjectAddress *object)
 	}
 }
 
+/*
+ * LockNotPinnedObjectById
+ *
+ * Lock the object if it is not pinned.  This is a convenience wrapper
+ * for callers that need to lock a referenced object before a permission
+ * check, converting permission check before lock to lock before permission
+ * check.
+ */
+void
+LockNotPinnedObjectById(Oid classid, Oid objid)
+{
+	ObjectAddress object;
+
+	ObjectAddressSet(object, classid, objid);
+
+	LockNotPinnedObject(&object);
+}
+
 /*
  * recordDependencyOnExpr - find expression dependencies
  *
@@ -1960,8 +1978,11 @@ find_expr_references_walker(Node *node,
 					objoid = DatumGetObjectId(con->constvalue);
 					if (SearchSysCacheExists1(PROCOID,
 											  ObjectIdGetDatum(objoid)))
+					{
+						LockNotPinnedObjectById(ProcedureRelationId, objoid);
 						add_object_address(ProcedureRelationId, objoid, 0,
 										   context->addrs);
+					}
 					break;
 				case REGOPEROID:
 				case REGOPERATOROID:
@@ -2057,6 +2078,7 @@ find_expr_references_walker(Node *node,
 	{
 		FuncExpr   *funcexpr = (FuncExpr *) node;
 
+		LockNotPinnedObjectById(ProcedureRelationId, funcexpr->funcid);
 		add_object_address(ProcedureRelationId, funcexpr->funcid, 0,
 						   context->addrs);
 		/* fall through to examine arguments */
diff --git a/src/backend/catalog/namespace.c b/src/backend/catalog/namespace.c
index 56b87d878e8..7dec076e111 100644
--- a/src/backend/catalog/namespace.c
+++ b/src/backend/catalog/namespace.c
@@ -3512,6 +3512,7 @@ LookupCreationNamespace(const char *nspname)
 
 	namespaceId = get_namespace_oid(nspname, false);
 
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
diff --git a/src/backend/catalog/pg_aggregate.c b/src/backend/catalog/pg_aggregate.c
index 243b952b9cc..41b390e8d96 100644
--- a/src/backend/catalog/pg_aggregate.c
+++ b/src/backend/catalog/pg_aggregate.c
@@ -586,22 +586,26 @@ AggregateCreate(const char *aggName,
 	 */
 	for (i = 0; i < numArgs; i++)
 	{
+		LockNotPinnedObjectById(TypeRelationId, aggArgTypes[i]);
 		aclresult = object_aclcheck(TypeRelationId, aggArgTypes[i], GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, aggArgTypes[i]);
 	}
 
+	LockNotPinnedObjectById(TypeRelationId, aggTransType);
 	aclresult = object_aclcheck(TypeRelationId, aggTransType, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, aggTransType);
 
 	if (OidIsValid(aggmTransType))
 	{
+		LockNotPinnedObjectById(TypeRelationId, aggmTransType);
 		aclresult = object_aclcheck(TypeRelationId, aggmTransType, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, aggmTransType);
 	}
 
+	LockNotPinnedObjectById(TypeRelationId, finaltype);
 	aclresult = object_aclcheck(TypeRelationId, finaltype, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, finaltype);
@@ -909,6 +913,7 @@ lookup_agg_function(List *fnName,
 	}
 
 	/* Check aggregate creator has permission to call the function */
+	LockNotPinnedObjectById(ProcedureRelationId, fnOid);
 	aclresult = object_aclcheck(ProcedureRelationId, fnOid, GetUserId(), ACL_EXECUTE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FUNCTION, get_func_name(fnOid));
diff --git a/src/backend/catalog/pg_cast.c b/src/backend/catalog/pg_cast.c
index 5119c2acda2..bb48e686f83 100644
--- a/src/backend/catalog/pg_cast.c
+++ b/src/backend/catalog/pg_cast.c
@@ -105,6 +105,7 @@ CastCreate(Oid sourcetypeid, Oid targettypeid,
 	/* dependency on function */
 	if (OidIsValid(funcid))
 	{
+		LockNotPinnedObjectById(ProcedureRelationId, funcid);
 		ObjectAddressSet(referenced, ProcedureRelationId, funcid);
 		add_exact_object_address(&referenced, addrs);
 	}
diff --git a/src/backend/catalog/pg_operator.c b/src/backend/catalog/pg_operator.c
index 6b90c774c18..a222358d081 100644
--- a/src/backend/catalog/pg_operator.c
+++ b/src/backend/catalog/pg_operator.c
@@ -654,6 +654,7 @@ get_other_operator(List *otherOp, Oid otherLeftTypeId, Oid otherRightTypeId,
 
 	/* not in catalogs, different from operator, so make shell */
 
+	LockNotPinnedObjectById(NamespaceRelationId, otherNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, otherNamespace, GetUserId(),
 								ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
@@ -876,6 +877,7 @@ makeOperatorDependencies(HeapTuple tuple,
 	/* Dependency on namespace */
 	if (OidIsValid(oper->oprnamespace))
 	{
+		LockNotPinnedObjectById(NamespaceRelationId, oper->oprnamespace);
 		ObjectAddressSet(referenced, NamespaceRelationId, oper->oprnamespace);
 		add_exact_object_address(&referenced, addrs);
 	}
diff --git a/src/backend/commands/aggregatecmds.c b/src/backend/commands/aggregatecmds.c
index 41b45dc6402..c2fb111daf3 100644
--- a/src/backend/commands/aggregatecmds.c
+++ b/src/backend/commands/aggregatecmds.c
@@ -22,6 +22,7 @@
  */
 #include "postgres.h"
 
+#include "catalog/dependency.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_aggregate.h"
 #include "catalog/pg_namespace.h"
@@ -101,6 +102,7 @@ DefineAggregate(ParseState *pstate,
 	aggNamespace = QualifiedNameGetCreationNamespace(name, &aggName);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, aggNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, aggNamespace, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
diff --git a/src/backend/commands/alter.c b/src/backend/commands/alter.c
index 74ceb5fe20d..03516cb0d2e 100644
--- a/src/backend/commands/alter.c
+++ b/src/backend/commands/alter.c
@@ -221,6 +221,7 @@ AlterObjectRename_internal(Relation rel, Oid objectId, const char *new_name)
 		/* User must have CREATE privilege on the namespace */
 		if (OidIsValid(namespaceId))
 		{
+			LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 			aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(),
 										ACL_CREATE);
 			if (aclresult != ACLCHECK_OK)
@@ -752,6 +753,7 @@ AlterObjectNamespace_internal(Relation rel, Oid objid, Oid nspOid)
 						   NameStr(*(DatumGetName(name))));
 
 		/* User must have CREATE privilege on new namespace */
+		LockNotPinnedObjectById(NamespaceRelationId, nspOid);
 		aclresult = object_aclcheck(NamespaceRelationId, nspOid, GetUserId(), ACL_CREATE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -1014,6 +1016,7 @@ AlterObjectOwner_internal(Oid classId, Oid objectId, Oid new_ownerId)
 			{
 				AclResult	aclresult;
 
+				LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 				aclresult = object_aclcheck(NamespaceRelationId, namespaceId, new_ownerId,
 											ACL_CREATE);
 				if (aclresult != ACLCHECK_OK)
diff --git a/src/backend/commands/collationcmds.c b/src/backend/commands/collationcmds.c
index 0bc31ec2b6f..fa5dad1aa1f 100644
--- a/src/backend/commands/collationcmds.c
+++ b/src/backend/commands/collationcmds.c
@@ -21,6 +21,7 @@
 #include "access/htup_details.h"
 #include "access/table.h"
 #include "access/xact.h"
+#include "catalog/dependency.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
 #include "catalog/objectaccess.h"
@@ -82,6 +83,7 @@ DefineCollation(ParseState *pstate, List *names, List *parameters, bool if_not_e
 
 	collNamespace = QualifiedNameGetCreationNamespace(names, &collName);
 
+	LockNotPinnedObjectById(NamespaceRelationId, collNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, collNamespace, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
diff --git a/src/backend/commands/conversioncmds.c b/src/backend/commands/conversioncmds.c
index 5f2022d3072..d60ecdb711a 100644
--- a/src/backend/commands/conversioncmds.c
+++ b/src/backend/commands/conversioncmds.c
@@ -14,6 +14,7 @@
  */
 #include "postgres.h"
 
+#include "catalog/dependency.h"
 #include "catalog/pg_conversion.h"
 #include "catalog/pg_namespace.h"
 #include "catalog/pg_proc.h"
@@ -50,6 +51,7 @@ CreateConversionCommand(CreateConversionStmt *stmt)
 													&conversion_name);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -97,6 +99,7 @@ CreateConversionCommand(CreateConversionStmt *stmt)
 						NameListToString(func_name), "integer")));
 
 	/* Check we have EXECUTE rights for the function */
+	LockNotPinnedObjectById(ProcedureRelationId, funcoid);
 	aclresult = object_aclcheck(ProcedureRelationId, funcoid, GetUserId(), ACL_EXECUTE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FUNCTION,
diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c
index a330b5fd6ce..4e82a010788 100644
--- a/src/backend/commands/extension.c
+++ b/src/backend/commands/extension.c
@@ -3283,6 +3283,7 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o
 					   extensionName);
 
 	/* Permission check: must have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, nspOid);
 	aclresult = object_aclcheck(NamespaceRelationId, nspOid, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA, newschema);
diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c
index c4852be2eb2..dd31e260739 100644
--- a/src/backend/commands/foreigncmds.c
+++ b/src/backend/commands/foreigncmds.c
@@ -377,6 +377,7 @@ AlterForeignServerOwner_internal(Relation rel, HeapTuple tup, Oid newOwnerId)
 			check_can_set_role(GetUserId(), newOwnerId);
 
 			/* New owner must have USAGE privilege on foreign-data wrapper */
+			LockNotPinnedObjectById(ForeignDataWrapperRelationId, form->srvfdw);
 			aclresult = object_aclcheck(ForeignDataWrapperRelationId, form->srvfdw, newOwnerId, ACL_USAGE);
 			if (aclresult != ACLCHECK_OK)
 			{
@@ -997,6 +998,7 @@ CreateForeignServer(CreateForeignServerStmt *stmt)
 	 */
 	fdw = GetForeignDataWrapperByName(stmt->fdwname, false);
 
+	LockNotPinnedObjectById(ForeignDataWrapperRelationId, fdw->fdwid);
 	aclresult = object_aclcheck(ForeignDataWrapperRelationId, fdw->fdwid, ownerId, ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FDW, fdw->fdwname);
@@ -1188,6 +1190,7 @@ user_mapping_ddl_aclcheck(Oid umuserid, Oid serverid, const char *servername)
 		{
 			AclResult	aclresult;
 
+			LockNotPinnedObjectById(ForeignServerRelationId, serverid);
 			aclresult = object_aclcheck(ForeignServerRelationId, serverid, curuserid, ACL_USAGE);
 			if (aclresult != ACLCHECK_OK)
 				aclcheck_error(aclresult, OBJECT_FOREIGN_SERVER, servername);
@@ -1539,6 +1542,7 @@ CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid)
 	 * get the actual FDW for option validation etc.
 	 */
 	server = GetForeignServerByName(stmt->servername, false);
+	LockNotPinnedObjectById(ForeignServerRelationId, server->serverid);
 	aclresult = object_aclcheck(ForeignServerRelationId, server->serverid, ownerId, ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FOREIGN_SERVER, server->servername);
@@ -1598,6 +1602,7 @@ ImportForeignSchema(ImportForeignSchemaStmt *stmt)
 
 	/* Check that the foreign server exists and that we have USAGE on it */
 	server = GetForeignServerByName(stmt->server_name, false);
+	LockNotPinnedObjectById(ForeignServerRelationId, server->serverid);
 	aclresult = object_aclcheck(ForeignServerRelationId, server->serverid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FOREIGN_SERVER, server->servername);
diff --git a/src/backend/commands/functioncmds.c b/src/backend/commands/functioncmds.c
index 3afd762e9dc..9aec534c390 100644
--- a/src/backend/commands/functioncmds.c
+++ b/src/backend/commands/functioncmds.c
@@ -146,6 +146,7 @@ compute_return_type(TypeName *returnType, Oid languageOid,
 				 errdetail("Creating a shell type definition.")));
 		namespaceId = QualifiedNameGetCreationNamespace(returnType->names,
 														&typname);
+		LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 		aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(),
 									ACL_CREATE);
 		if (aclresult != ACLCHECK_OK)
@@ -158,6 +159,7 @@ compute_return_type(TypeName *returnType, Oid languageOid,
 		CommandCounterIncrement();
 	}
 
+	LockNotPinnedObjectById(TypeRelationId, rettype);
 	aclresult = object_aclcheck(TypeRelationId, rettype, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, rettype);
@@ -274,6 +276,7 @@ interpret_function_parameter_list(ParseState *pstate,
 			toid = InvalidOid;	/* keep compiler quiet */
 		}
 
+		LockNotPinnedObjectById(TypeRelationId, toid);
 		aclresult = object_aclcheck(TypeRelationId, toid, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, toid);
@@ -1071,6 +1074,7 @@ CreateFunction(ParseState *pstate, CreateFunctionStmt *stmt)
 													&funcname);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -1125,6 +1129,7 @@ CreateFunction(ParseState *pstate, CreateFunctionStmt *stmt)
 	if (languageStruct->lanpltrusted)
 	{
 		/* if trusted language, need USAGE privilege */
+		LockNotPinnedObjectById(LanguageRelationId, languageOid);
 		aclresult = object_aclcheck(LanguageRelationId, languageOid, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_LANGUAGE,
@@ -1582,10 +1587,12 @@ CreateCast(CreateCastStmt *stmt)
 						format_type_be(sourcetypeid),
 						format_type_be(targettypeid))));
 
+	LockNotPinnedObjectById(TypeRelationId, sourcetypeid);
 	aclresult = object_aclcheck(TypeRelationId, sourcetypeid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, sourcetypeid);
 
+	LockNotPinnedObjectById(TypeRelationId, targettypeid);
 	aclresult = object_aclcheck(TypeRelationId, targettypeid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, targettypeid);
@@ -1874,6 +1881,7 @@ CreateTransform(CreateTransformStmt *stmt)
 	if (!object_ownercheck(TypeRelationId, typeid, GetUserId()))
 		aclcheck_error_type(ACLCHECK_NOT_OWNER, typeid);
 
+	LockNotPinnedObjectById(TypeRelationId, typeid);
 	aclresult = object_aclcheck(TypeRelationId, typeid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, typeid);
@@ -1883,6 +1891,7 @@ CreateTransform(CreateTransformStmt *stmt)
 	 */
 	langid = get_language_oid(stmt->lang, false);
 
+	LockNotPinnedObjectById(LanguageRelationId, langid);
 	aclresult = object_aclcheck(LanguageRelationId, langid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_LANGUAGE, stmt->lang);
@@ -1897,6 +1906,7 @@ CreateTransform(CreateTransformStmt *stmt)
 		if (!object_ownercheck(ProcedureRelationId, fromsqlfuncid, GetUserId()))
 			aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_FUNCTION, NameListToString(stmt->fromsql->objname));
 
+		LockNotPinnedObjectById(ProcedureRelationId, fromsqlfuncid);
 		aclresult = object_aclcheck(ProcedureRelationId, fromsqlfuncid, GetUserId(), ACL_EXECUTE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_FUNCTION, NameListToString(stmt->fromsql->objname));
@@ -1923,6 +1933,7 @@ CreateTransform(CreateTransformStmt *stmt)
 		if (!object_ownercheck(ProcedureRelationId, tosqlfuncid, GetUserId()))
 			aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_FUNCTION, NameListToString(stmt->tosql->objname));
 
+		LockNotPinnedObjectById(ProcedureRelationId, tosqlfuncid);
 		aclresult = object_aclcheck(ProcedureRelationId, tosqlfuncid, GetUserId(), ACL_EXECUTE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_FUNCTION, NameListToString(stmt->tosql->objname));
diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 9ab74c8df0a..6216ab2a5f5 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -25,6 +25,7 @@
 #include "access/tableam.h"
 #include "access/xact.h"
 #include "catalog/catalog.h"
+#include "catalog/dependency.h"
 #include "catalog/index.h"
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
@@ -770,6 +771,7 @@ DefineIndex(ParseState *pstate,
 	{
 		AclResult	aclresult;
 
+		LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 		aclresult = object_aclcheck(NamespaceRelationId, namespaceId, root_save_userid,
 									ACL_CREATE);
 		if (aclresult != ACLCHECK_OK)
diff --git a/src/backend/commands/opclasscmds.c b/src/backend/commands/opclasscmds.c
index 7493a9ccc06..ad71ee53cb2 100644
--- a/src/backend/commands/opclasscmds.c
+++ b/src/backend/commands/opclasscmds.c
@@ -363,6 +363,7 @@ DefineOpClass(CreateOpClassStmt *stmt)
 													 &opcname);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceoid);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceoid, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -801,6 +802,7 @@ DefineOpFamily(CreateOpFamilyStmt *stmt)
 													 &opfname);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceoid);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceoid, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
diff --git a/src/backend/commands/operatorcmds.c b/src/backend/commands/operatorcmds.c
index 3e7b09b3494..bb2ce833bdd 100644
--- a/src/backend/commands/operatorcmds.c
+++ b/src/backend/commands/operatorcmds.c
@@ -33,6 +33,7 @@
 
 #include "access/htup_details.h"
 #include "access/table.h"
+#include "catalog/dependency.h"
 #include "catalog/indexing.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_namespace.h"
@@ -92,6 +93,7 @@ DefineOperator(List *names, List *parameters)
 	oprNamespace = QualifiedNameGetCreationNamespace(names, &oprName);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, oprNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, oprNamespace, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -189,6 +191,7 @@ DefineOperator(List *names, List *parameters)
 
 	if (typeName1)
 	{
+		LockNotPinnedObjectById(TypeRelationId, typeId1);
 		aclresult = object_aclcheck(TypeRelationId, typeId1, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, typeId1);
@@ -196,6 +199,7 @@ DefineOperator(List *names, List *parameters)
 
 	if (typeName2)
 	{
+		LockNotPinnedObjectById(TypeRelationId, typeId2);
 		aclresult = object_aclcheck(TypeRelationId, typeId2, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, typeId2);
@@ -227,12 +231,14 @@ DefineOperator(List *names, List *parameters)
 	 * necessary, since EXECUTE will be checked at any attempted use of the
 	 * operator, but it seems like a good idea anyway.
 	 */
+	LockNotPinnedObjectById(ProcedureRelationId, functionOid);
 	aclresult = object_aclcheck(ProcedureRelationId, functionOid, GetUserId(), ACL_EXECUTE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FUNCTION,
 					   NameListToString(functionName));
 
 	rettype = get_func_rettype(functionOid);
+	LockNotPinnedObjectById(TypeRelationId, rettype);
 	aclresult = object_aclcheck(TypeRelationId, rettype, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, rettype);
@@ -312,6 +318,7 @@ ValidateRestrictionEstimator(List *restrictionName)
 	{
 		AclResult	aclresult;
 
+		LockNotPinnedObjectById(ProcedureRelationId, restrictionOid);
 		aclresult = object_aclcheck(ProcedureRelationId, restrictionOid,
 									GetUserId(), ACL_EXECUTE);
 		if (aclresult != ACLCHECK_OK)
@@ -382,6 +389,7 @@ ValidateJoinEstimator(List *joinName)
 	{
 		AclResult	aclresult;
 
+		LockNotPinnedObjectById(ProcedureRelationId, joinOid);
 		aclresult = object_aclcheck(ProcedureRelationId, joinOid,
 									GetUserId(), ACL_EXECUTE);
 		if (aclresult != ACLCHECK_OK)
diff --git a/src/backend/commands/statscmds.c b/src/backend/commands/statscmds.c
index b354723be44..6afef0f55c4 100644
--- a/src/backend/commands/statscmds.c
+++ b/src/backend/commands/statscmds.c
@@ -185,6 +185,7 @@ CreateStatistics(CreateStatsStmt *stmt, bool check_rights)
 	{
 		AclResult	aclresult;
 
+		LockNotPinnedObjectById(NamespaceRelationId, namespaceId);
 		aclresult = object_aclcheck(NamespaceRelationId, namespaceId,
 									GetUserId(), ACL_CREATE);
 		if (aclresult != ACLCHECK_OK)
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 523959ba0ce..08dea4b67b3 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -745,6 +745,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
 		conninfo = NULL;
 
 		server = GetForeignServerByName(stmt->servername, false);
+		LockNotPinnedObjectById(ForeignServerRelationId, server->serverid);
 		aclresult = object_aclcheck(ForeignServerRelationId, server->serverid, owner, ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_FOREIGN_SERVER, server->servername);
@@ -1833,6 +1834,7 @@ AlterSubscription(ParseState *pstate, AlterSubscriptionStmt *stmt,
 				 * the server.
 				 */
 				new_server = GetForeignServerByName(stmt->servername, false);
+				LockNotPinnedObjectById(ForeignServerRelationId, new_server->serverid);
 				aclresult = object_aclcheck(ForeignServerRelationId,
 											new_server->serverid,
 											form->subowner, ACL_USAGE);
@@ -2262,6 +2264,7 @@ DropSubscription(DropSubscriptionStmt *stmt, bool isTopLevel)
 		ForeignServer *server;
 
 		server = GetForeignServer(form->subserver);
+		LockNotPinnedObjectById(ForeignServerRelationId, form->subserver);
 		aclresult = object_aclcheck(ForeignServerRelationId, form->subserver,
 									form->subowner, ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
@@ -2606,6 +2609,7 @@ AlterSubscriptionOwner_internal(Relation rel, HeapTuple tup, Oid newOwnerId)
 	{
 		ForeignServer *server = GetForeignServer(form->subserver);
 
+		LockNotPinnedObjectById(ForeignServerRelationId, server->serverid);
 		aclresult = object_aclcheck(ForeignServerRelationId, server->serverid, newOwnerId, ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			ereport(ERROR,
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 92b0f38c353..d95ef23a53f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -30,6 +30,7 @@
 #include "access/xlog.h"
 #include "access/xloginsert.h"
 #include "catalog/catalog.h"
+#include "catalog/dependency.h"
 #include "catalog/heap.h"
 #include "catalog/index.h"
 #include "catalog/namespace.h"
@@ -997,6 +998,7 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 
 		ofTypeId = typenameTypeId(NULL, stmt->ofTypename);
 
+		LockNotPinnedObjectById(TypeRelationId, ofTypeId);
 		aclresult = object_aclcheck(TypeRelationId, ofTypeId, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, ofTypeId);
@@ -1462,6 +1464,7 @@ BuildDescForRelation(const List *columns)
 		attname = entry->colname;
 		typenameTypeIdAndMod(NULL, entry->typeName, &atttypid, &atttypmod);
 
+		LockNotPinnedObjectById(TypeRelationId, atttypid);
 		aclresult = object_aclcheck(TypeRelationId, atttypid, GetUserId(), ACL_USAGE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error_type(aclresult, atttypid);
@@ -13941,6 +13944,7 @@ checkFkeyPermissions(Relation rel, int16 *attnums, int natts)
 	int			i;
 
 	/* Okay if we have relation-level REFERENCES permission */
+	LockNotPinnedObjectById(RelationRelationId, RelationGetRelid(rel));
 	aclresult = pg_class_aclcheck(RelationGetRelid(rel), roleid,
 								  ACL_REFERENCES);
 	if (aclresult == ACLCHECK_OK)
@@ -14724,6 +14728,7 @@ ATPrepAlterColumnType(List **wqueue,
 	/* Look up the target type */
 	typenameTypeIdAndMod(pstate, typeName, &targettype, &targettypmod);
 
+	LockNotPinnedObjectById(TypeRelationId, targettype);
 	aclresult = object_aclcheck(TypeRelationId, targettype, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, targettype);
@@ -16476,6 +16481,7 @@ ATExecChangeOwner(Oid relationOid, Oid newOwnerId, bool recursing, LOCKMODE lock
 				check_can_set_role(GetUserId(), newOwnerId);
 
 				/* New owner must have CREATE privilege on namespace */
+				LockNotPinnedObjectById(NamespaceRelationId, namespaceOid);
 				aclresult = object_aclcheck(NamespaceRelationId, namespaceOid, newOwnerId,
 											ACL_CREATE);
 				if (aclresult != ACLCHECK_OK)
@@ -19882,6 +19888,7 @@ RangeVarCallbackForAlterRelation(const RangeVar *rv, Oid relid, Oid oldrelid,
 	 */
 	if (IsA(stmt, RenameStmt))
 	{
+		LockNotPinnedObjectById(NamespaceRelationId, classform->relnamespace);
 		aclresult = object_aclcheck(NamespaceRelationId, classform->relnamespace,
 									GetUserId(), ACL_CREATE);
 		if (aclresult != ACLCHECK_OK)
diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index b87b4b40d07..3c5ca8fe7e3 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -350,6 +350,7 @@ CreateTriggerFiringOn(const CreateTrigStmt *stmt, const char *queryString,
 
 		if (OidIsValid(constrrelid))
 		{
+			LockNotPinnedObjectById(RelationRelationId, constrrelid);
 			aclresult = pg_class_aclcheck(constrrelid, GetUserId(),
 										  ACL_TRIGGER);
 			if (aclresult != ACLCHECK_OK)
@@ -695,6 +696,7 @@ CreateTriggerFiringOn(const CreateTrigStmt *stmt, const char *queryString,
 		funcoid = LookupFuncName(stmt->funcname, 0, NULL, false);
 	if (!isInternal)
 	{
+		LockNotPinnedObjectById(ProcedureRelationId, funcoid);
 		aclresult = object_aclcheck(ProcedureRelationId, funcoid, GetUserId(), ACL_EXECUTE);
 		if (aclresult != ACLCHECK_OK)
 			aclcheck_error(aclresult, OBJECT_FUNCTION,
diff --git a/src/backend/commands/tsearchcmds.c b/src/backend/commands/tsearchcmds.c
index a12ce33bae4..eb9e68bbb91 100644
--- a/src/backend/commands/tsearchcmds.c
+++ b/src/backend/commands/tsearchcmds.c
@@ -414,6 +414,7 @@ DefineTSDictionary(List *names, List *parameters)
 	namespaceoid = QualifiedNameGetCreationNamespace(names, &dictname);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceoid);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceoid, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -917,6 +918,7 @@ DefineTSConfiguration(List *names, List *parameters, ObjectAddress *copied)
 	namespaceoid = QualifiedNameGetCreationNamespace(names, &cfgname);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, namespaceoid);
 	aclresult = object_aclcheck(NamespaceRelationId, namespaceoid, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
diff --git a/src/backend/commands/typecmds.c b/src/backend/commands/typecmds.c
index c4c3cdb5461..0e01dda90ba 100644
--- a/src/backend/commands/typecmds.c
+++ b/src/backend/commands/typecmds.c
@@ -39,6 +39,7 @@
 #include "access/xact.h"
 #include "catalog/binary_upgrade.h"
 #include "catalog/catalog.h"
+#include "catalog/dependency.h"
 #include "catalog/heap.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_am.h"
@@ -739,6 +740,7 @@ DefineDomain(ParseState *pstate, CreateDomainStmt *stmt)
 														&domainName);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, domainNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, domainNamespace, GetUserId(),
 								ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
@@ -788,6 +790,7 @@ DefineDomain(ParseState *pstate, CreateDomainStmt *stmt)
 						TypeNameToString(stmt->typeName)),
 				 parser_errposition(pstate, stmt->typeName->location)));
 
+	LockNotPinnedObjectById(TypeRelationId, basetypeoid);
 	aclresult = object_aclcheck(TypeRelationId, basetypeoid, GetUserId(), ACL_USAGE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error_type(aclresult, basetypeoid);
@@ -1195,6 +1198,7 @@ DefineEnum(CreateEnumStmt *stmt)
 													  &enumName);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, enumNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, enumNamespace, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -1420,6 +1424,7 @@ DefineRange(ParseState *pstate, CreateRangeStmt *stmt)
 													  &typeName);
 
 	/* Check we have creation rights in target namespace */
+	LockNotPinnedObjectById(NamespaceRelationId, typeNamespace);
 	aclresult = object_aclcheck(NamespaceRelationId, typeNamespace, GetUserId(), ACL_CREATE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_SCHEMA,
@@ -1496,6 +1501,7 @@ DefineRange(ParseState *pstate, CreateRangeStmt *stmt)
 																	&multirangeTypeName);
 
 			/* Check we have creation rights in target namespace */
+			LockNotPinnedObjectById(NamespaceRelationId, multirangeNamespace);
 			aclresult = object_aclcheck(NamespaceRelationId, multirangeNamespace,
 										GetUserId(), ACL_CREATE);
 			if (aclresult != ACLCHECK_OK)
@@ -2421,6 +2427,7 @@ findRangeCanonicalFunction(List *procname, Oid typeOid)
 						func_signature_string(procname, 1, NIL, argList))));
 
 	/* Also, range type's creator must have permission to call function */
+	LockNotPinnedObjectById(ProcedureRelationId, procOid);
 	aclresult = object_aclcheck(ProcedureRelationId, procOid, GetUserId(), ACL_EXECUTE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FUNCTION, get_func_name(procOid));
@@ -2464,6 +2471,7 @@ findRangeSubtypeDiffFunction(List *procname, Oid subtype)
 						func_signature_string(procname, 2, NIL, argList))));
 
 	/* Also, range type's creator must have permission to call function */
+	LockNotPinnedObjectById(ProcedureRelationId, procOid);
 	aclresult = object_aclcheck(ProcedureRelationId, procOid, GetUserId(), ACL_EXECUTE);
 	if (aclresult != ACLCHECK_OK)
 		aclcheck_error(aclresult, OBJECT_FUNCTION, get_func_name(procOid));
@@ -3963,6 +3971,7 @@ AlterTypeOwner(List *names, Oid newOwnerId, ObjectType objecttype)
 			check_can_set_role(GetUserId(), newOwnerId);
 
 			/* New owner must have CREATE privilege on namespace */
+			LockNotPinnedObjectById(NamespaceRelationId, typTup->typnamespace);
 			aclresult = object_aclcheck(NamespaceRelationId, typTup->typnamespace,
 										newOwnerId,
 										ACL_CREATE);
diff --git a/src/include/catalog/dependency.h b/src/include/catalog/dependency.h
index fa508ea70c6..ac1d902e912 100644
--- a/src/include/catalog/dependency.h
+++ b/src/include/catalog/dependency.h
@@ -102,6 +102,7 @@ typedef struct ObjectAddresses ObjectAddresses;
 
 extern void AcquireDeletionLock(const ObjectAddress *object, int flags);
 extern void LockNotPinnedObject(const ObjectAddress *object);
+extern void LockNotPinnedObjectById(Oid classid, Oid objid);
 extern bool isObjectPinned(const ObjectAddress *object);
 
 extern void ReleaseDeletionLock(const ObjectAddress *object);
-- 
2.34.1


--FjYZKkuBdH+F20sI
Content-Type: text/x-diff; charset=utf-8
Content-Disposition: attachment;
	filename="v21-0003-Add-Assert-guard-to-detect-permission-check-befo.patch"
Content-Transfer-Encoding: 8bit



^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread

* [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column
@ 2026-05-14 21:51 Alberto Piai <[email protected]>
  0 siblings, 0 replies; 177+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

This builds upon basic support for

... ALTER COLUMN ... ADD GENERATED ALWAYS AS (expr) STORED

If we can find a constraint which proves that the given column is
already always equal to the new generated column expression, skip the
expensive rewrite of the table.

The check constraint must use an equality operator which is mergejoinable, and
the expression must match exactly the generated column's default
expression.
---
 src/backend/catalog/pg_constraint.c       |  73 +++++++++++
 src/backend/commands/tablecmds.c          |  40 +++---
 src/include/catalog/pg_constraint.h       |   2 +
 src/test/regress/expected/alter_table.out | 153 ++++++++++++++++++++++
 src/test/regress/sql/alter_table.sql      |  83 ++++++++++++
 5 files changed, 335 insertions(+), 16 deletions(-)

diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c
index b12765ae691..37628f65dc1 100644
--- a/src/backend/catalog/pg_constraint.c
+++ b/src/backend/catalog/pg_constraint.c
@@ -29,6 +29,7 @@
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "common/int.h"
+#include "nodes/nodeFuncs.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/fmgroids.h"
@@ -694,6 +695,78 @@ findDomainNotNullConstraint(Oid typid)
 	return retval;
 }
 
+/*
+ * Given a relation, an attnum and a (cooked) expression, this returns true if
+ * it finds a CHECK constraint which proves that the given column is equal to
+ * the expression.
+ *
+ * The constraint must use a mergejoinable operator for the type of the column,
+ * a concept used by the planner as well to infer equivalence classes on the
+ * terms in a query (see op_mergejoinable()).
+ *
+ * The expressions are compared structurally, so they must match exactly for
+ * this check to succeed.
+ */
+bool
+findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+									const Node *target_expr)
+{
+	Relation	pg_constraint;
+	HeapTuple	conTup;
+	SysScanDesc scan;
+	ScanKeyData key;
+	bool		found = false;
+
+	pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+	ScanKeyInit(&key,
+				Anum_pg_constraint_conrelid,
+				BTEqualStrategyNumber, F_OIDEQ,
+				ObjectIdGetDatum(relid));
+	scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+							  true, NULL, 1, &key);
+
+	while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+	{
+		Form_pg_constraint con = GETSTRUCT(conTup);
+		char	   *conbin;
+		Datum		val;
+		Node	   *conexpr;
+
+		if (con->contype != CONSTRAINT_CHECK)
+			continue;
+		if (!con->convalidated)
+			continue;
+
+		val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+									 Anum_pg_constraint_conbin);
+		conbin = TextDatumGetCString(val);
+		conexpr = stringToNode(conbin);
+
+		if (IsA(conexpr, OpExpr))
+		{
+			OpExpr	   *op = (OpExpr *) conexpr;
+
+			if (list_length(op->args) == 2 && IsA(linitial(op->args), Var))
+			{
+				Var		   *var = linitial(op->args);
+
+				if (var->varattno == attnum &&
+					op_mergejoinable(op->opno, exprType((Node *) var)) &&
+					equal(lsecond(op->args), target_expr))
+				{
+					found = true;
+					break;
+				}
+			}
+		}
+	}
+
+	systable_endscan(scan);
+	table_close(pg_constraint, AccessShareLock);
+
+	return found;
+}
+
 /*
  * Given a pg_constraint tuple for a not-null constraint, return the column
  * number it is for.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 39faef0a114..4afa157ee9e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8939,6 +8939,7 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 	Relation	pg_attribute;
 	List	   *newcons;
 	CookedConstraint *cookedDef;
+	bool		rewrite;
 
 	Assert(def->raw_expr != NULL);
 	Assert(def->cooked_expr == NULL);
@@ -9013,25 +9014,32 @@ ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
 
 	cookedDef = linitial(newcons);
 
-	/*
-	 * Clear all the missing values if we're rewriting the table, since this
-	 * renders them pointless.
-	 */
-	RelationClearMissing(rel);
+	rewrite = !findStructuralCheckConstraintOnAttr(RelationGetRelid(rel),
+												   attnum,
+												   cookedDef->expr);
 
-	/* Make above changes visible */
-	CommandCounterIncrement();
+	if (rewrite)
+	{
+		/*
+		 * Clear all the missing values if we're rewriting the table, since
+		 * this renders them pointless.
+		 */
+		RelationClearMissing(rel);
 
-	/* Drop any pg_statistic entry for the column */
-	RemoveStatistics(RelationGetRelid(rel), attnum);
+		/* Make above changes visible */
+		CommandCounterIncrement();
 
-	/* Schedule a rewrite */
-	newval = palloc0_object(NewColumnValue);
-	newval->attnum = attnum;
-	newval->expr = (Expr *) cookedDef->expr;
-	newval->is_generated = true;
-	tab->newvals = lappend(tab->newvals, newval);
-	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+		/* Drop any pg_statistic entry for the column */
+		RemoveStatistics(RelationGetRelid(rel), attnum);
+
+		/* Schedule a rewrite */
+		newval = palloc0_object(NewColumnValue);
+		newval->attnum = attnum;
+		newval->expr = (Expr *) cookedDef->expr;
+		newval->is_generated = true;
+		tab->newvals = lappend(tab->newvals, newval);
+		tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+	}
 
 	InvokeObjectPostAlterHook(RelationRelationId,
 							  RelationGetRelid(rel), attnum);
diff --git a/src/include/catalog/pg_constraint.h b/src/include/catalog/pg_constraint.h
index 1b7fedf1750..7ac9e00c28b 100644
--- a/src/include/catalog/pg_constraint.h
+++ b/src/include/catalog/pg_constraint.h
@@ -266,6 +266,8 @@ extern char *ChooseConstraintName(const char *name1, const char *name2,
 extern HeapTuple findNotNullConstraintAttnum(Oid relid, AttrNumber attnum);
 extern HeapTuple findNotNullConstraint(Oid relid, const char *colname);
 extern HeapTuple findDomainNotNullConstraint(Oid typid);
+extern bool findStructuralCheckConstraintOnAttr(Oid relid, AttrNumber attnum,
+												const Node *target_expr);
 extern AttrNumber extractNotNullColumn(HeapTuple constrTup);
 extern bool AdjustNotNullInheritance(Oid relid, AttrNumber attnum, const char *new_conname,
 									 bool is_local, bool is_no_inherit, bool is_notvalid);
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 2d0ce414d12..b269736ab1a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4956,6 +4956,159 @@ select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 (1 row)
 
 drop table testgen.t3;
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2)) NOT VALID
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite 
+-------------
+ t
+(1 row)
+
+\d+ testgen.t4
+                                                 Table "testgen.t4"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 3) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b >= (a * 2))
+Not-null constraints:
+    "t4_b_not_null" NOT NULL "b"
+
+drop table testgen.t4;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+ locktype |           mode           
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype |        mode         
+----------+---------------------
+ relation | AccessShareLock
+ relation | AccessExclusiveLock
+(2 rows)
+
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+ did_skip_rewrite 
+------------------
+ t
+(1 row)
+
+\d+ testgen.t5
+                                                 Table "testgen.t5"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           |          | generated always as (a * 2) stored | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+
+drop function testgen.gen;
+drop table testgen.t5;
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 51d818d4995..9851093fb58 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3198,6 +3198,89 @@ select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
 select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
 drop table testgen.t3;
 
+-- turning a regular column into a stored generated column
+-- without rewriting the table (when a check constraint proves it isn't needed)
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before = :t4_filenode_after as did_skip_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint is not
+-- valid
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2) not valid;
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- turning a regular column into a stored generated column
+-- same as the previous case, but a rewrite happens since the constraint
+-- operator is not mergejoinable
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+\d+ testgen.t4
+drop table testgen.t4;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table testgen.t5 (a int);
+select pg_relation_filenode('testgen.t5') as t5_filenode_before \gset
+insert into testgen.t5 select x from generate_series(1, 5) x;
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+  new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+    before insert or update on testgen.t5
+    for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+    add constraint chk_gen_clause check (b = a * 2) not valid;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+insert into testgen.t5 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update testgen.t5 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table testgen.t5 validate constraint chk_gen_clause;
+select locktype, mode from pg_locks
+  where relation = 'testgen.t5'::regclass and granted;
+commit;
+-- now the schema update, which skips the rewrite because of the check
+begin;
+drop trigger testgen_gen on testgen.t5;
+alter table testgen.t5 alter column b
+    add generated always as (a * 2) stored;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+commit;
+select pg_relation_filenode('testgen.t5') as t5_filenode_after \gset
+select :t5_filenode_before = :t5_filenode_after as did_skip_rewrite;
+\d+ testgen.t5
+drop function testgen.gen;
+drop table testgen.t5;
+
 -- test support for partitioned tables and inheritance
 create table testgen.tpart (a int, b int) partition by hash (a);
 create table testgen.tpart_p1 partition of testgen.tpart
-- 
2.47.0


--zfrmj4necy5zy3mo--





^ permalink  raw  reply  [nested|flat] 177+ messages in thread


end of thread, other threads:[~2026-05-14 21:51 UTC | newest]

Thread overview: 177+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:28 [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-04-27 14:19 [PATCH v21 2/3] Lock referenced objects before permission checks in DDL commands Bertrand Drouvot <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated column Alberto Piai <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox