agora inbox for pgsql-hackers@postgresql.org
help / color / mirror / Atom feedAdding a stored generated column without long-lived locks
37+ messages / 5 participants
[nested] [flat]
* Adding a stored generated column without long-lived locks
@ 2026-03-17 10:31 Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-03-17 10:31 UTC (permalink / raw)
To: pgsql-hackers
Hi,
I recently needed to add a stored generated column to a table of
nontrivial size, and realized that currently there is no way to do that
without rewriting the table under an AccessExclusiveLock.
One way I think this could be achieved:
- allow turning an existing column into a stored generated column, by
default doing a table rewrite using the new stored column expression
- when doing the above, try to detect the presence of a check constraint
which proves that the contents of the column already match its defined
expression, and in that case skip the rewrite
This would open up a path to add such a column (GENERATED ALWAYS AS
(expr) STORED) without long-lived locks:
- add column c, nullable
- add trigger to set c = expr for new/updated rows
- add constraint check (c = expr) NOT VALID
- backfill the table at the appropriate pace
- VALIDATE the constraint
- alter the column c to be GENERATED ALWAYS AS (expr) STORED, which
would skip the rewrite because of the valid check constraint on c
- clean up the trigger and the constraint
To this effect, I started prototyping an alter table command
ALTER TABLE t ALTER COLUMN c ADD GENERATED ALWAYS AS (expr) STORED
The syntax seemed like a good fit because it's similar to the command to
change a column to be GENERATED AS IDENTITY, but I didn't spend a whole
lot of thought on the exact syntax yet.
The attached patches are a first prototype for discussion:
- patch v1-0001: add the command
- patch v1-0002: detect the check constraint and skip the rewrite
The check constraint must be of the form
(c = <expr>)
where `=` is a mergejoinable operator for the type c.
The <expr> in the constraint and in the column definition are matched
structurally, so they must match exactly.
Before spending more time on this, I wanted to bring this up for
discussion and to gauge interest in the idea.
Looking forward to your feedback!
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
Attachments:
[text/x-patch] v1-0001-Support-changing-a-column-into-a-stored-generated.patch (19.2K, ../../abkrpUwlGngF4e-d@phidippus.sen.work/2-v1-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From ac4d0ad95c72f9ff0c6a9757f724526b597d8420 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Tue, 17 Mar 2026 00:25:20 +0100
Subject: [PATCH v1 1/2] Support changing a column into a stored generated
column
This adds basic support for an ALTER TABLE ... ALTER COLUMN command to
turn a regular column into a stored generated column.
The syntax is chosen to be similar to
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
Since this is a first prototype, no thought has been given to
partitioned nor foreign tables, so these are not supported either.
This operation always rewrites the contents of the column using the new
generated expression.
---
src/backend/commands/tablecmds.c | 137 +++++++++++++++++-
src/backend/parser/gram.y | 31 ++++
src/include/nodes/parsenodes.h | 1 +
.../test_ddl_deparse/test_ddl_deparse.c | 2 +
src/test/regress/expected/alter_table.out | 122 ++++++++++++++++
src/test/regress/sql/alter_table.sql | 69 +++++++++
6 files changed, 361 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 67e42e5df29..e7386e81b07 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -760,6 +760,10 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4746,6 +4750,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
+ case AT_AddGeneratedAsExprStored:
cmd_lockmode = AccessExclusiveLock;
break;
@@ -5321,6 +5326,16 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
/* No command-specific prep needed */
pass = AT_PASS_MISC;
break;
+ case AT_AddGeneratedAsExprStored:
+ /* No support yet for: partitioned tables, foreign tables */
+ ATSimplePermissions(cmd->subtype, rel, ATT_TABLE);
+
+ /*
+ * This has similar mechanics to AT_SetExpression, let's use the
+ * same pass.
+ */
+ pass = AT_PASS_SET_EXPRESSION;
+ break;
default: /* oops */
elog(ERROR, "unrecognized alter table type: %d",
(int) cmd->subtype);
@@ -5733,6 +5748,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
ATExecSplitPartition(wqueue, tab, rel, (PartitionCmd *) cmd->def,
context);
break;
+ case AT_AddGeneratedAsExprStored:
+ cmd = ATParseTransformCmd(wqueue, tab, rel, cmd, false, lockmode,
+ cur_pass, context);
+ Assert(cmd != NULL);
+ address = ATExecAddGeneratedAsExprStored(tab, rel, cmd->name, (Constraint *) cmd->def);
+ break;
default: /* oops */
elog(ERROR, "unrecognized alter table type: %d",
(int) cmd->subtype);
@@ -6785,6 +6806,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... DROP IDENTITY";
case AT_ReAddStatistics:
return NULL; /* not real grammar */
+ case AT_AddGeneratedAsExprStored:
+ return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
}
return NULL;
@@ -8823,6 +8846,118 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ ObjectAddress address;
+ Expr *defval;
+ NewColumnValue *newval;
+ RawColumnDefault *rawEnt;
+ Relation pg_attribute;
+
+ Assert(def->raw_expr != NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ ReleaseSysCache(tuple);
+
+ /*
+ * Find everything that depends on the column (constraints, indexes, etc),
+ * and record enough information to let us recreate the objects.
+ */
+ RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+ rel, attnum, colName);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawEnt = palloc_object(RawColumnDefault);
+ rawEnt->attnum = attnum;
+ rawEnt->raw_default = def->raw_expr;
+ rawEnt->generated = def->generated_kind;
+ AddRelationNewConstraints(rel, list_make1(rawEnt), NIL,
+ false, true, false, NULL);
+
+ /* 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;
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
@@ -15290,7 +15425,7 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
SysScanDesc scan;
HeapTuple depTup;
- Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+ Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression || subtype == AT_AddGeneratedAsExprStored);
depRel = table_open(DependRelationId, RowExclusiveLock);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index c2584249603..74440e801d4 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2717,6 +2717,37 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->raw_expr = $9;
+ c->cooked_expr = NULL;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @5;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddGeneratedAsExprStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index ffadd667167..6b61513e6d0 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2568,6 +2568,7 @@ typedef enum AlterTableType
AT_SetIdentity, /* SET identity column options */
AT_DropIdentity, /* DROP IDENTITY */
AT_ReAddStatistics, /* internal to commands/tablecmds.c */
+ AT_AddGeneratedAsExprStored, /* ADD GENERATED ALWAYS AS (...) STORED */
} AlterTableType;
typedef struct AlterTableCmd /* one subcommand of an ALTER TABLE */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..7c1699d538c 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -315,6 +315,8 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_ReAddStatistics:
strtype = "(re) ADD STATS";
break;
+ case AT_AddGeneratedAsExprStored:
+ strtype = "ADD GENERATED ALWAYS AS (...) STORED";
}
if (subcmd->recurse)
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index ccd79dfecc0..75f64628aef 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4863,3 +4863,125 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+ Table "testgen.t2"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | plain | |
+Check constraints:
+ "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+ "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+ did_rewrite_idx
+-----------------
+ t
+(1 row)
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+ERROR: column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default as (a * 2) stored;
+ ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR: syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+ ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+ ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR: column "b" of relation "t2" is already a generated column
+-- not supported: partitioned tables
+create table testgen.tpart (a int, b int) partition by hash (a);
+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
+DETAIL: drop cascades to table testgen.t3
+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 f5f13bbd3e7..d776595a6ed 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,72 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+
+-- not supported: partitioned tables
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart alter column b add generated always as (a * 2) stored;
+
+drop schema testgen cascade;
--
2.51.2
[text/x-patch] v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch (18.8K, ../../abkrpUwlGngF4e-d@phidippus.sen.work/3-v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch)
download | inline diff:
From 9bc49fb58238d8be4305bd4dff6ecd31f79325a0 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Tue, 17 Mar 2026 00:28:48 +0100
Subject: [PATCH v1 2/2] Try to avoid a rewrite when adding a stored generated
column
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
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-04-07 09:09 ` Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-04-07 09:09 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Tue Mar 17, 2026 at 5:31 PM +07, Alberto Piai wrote:
> I recently needed to add a stored generated column to a table of
> nontrivial size, and realized that currently there is no way to do
> that without rewriting the table under an AccessExclusiveLock.
[...]
> To this effect, I started prototyping an alter table command
We currently have a way to change the expression of generated columns
(SET EXPRESSION) and a way to turn a generated column into a regular one
(DROP EXPRESSION). The new command would fit nicely and provide the
missing piece of functionality: turning an existing column into a
generated column.
A few thoughts:
- since this is specifically useful for *stored* generated columns (to
have a way to avoid a rewrite while the table is locked), I would
stick to my first proposal and require that STORED is specified
explicitly. It would still be possible to remove this requirement and
expand to virtual generated columns, should the need for this arise in
the future (I just don't see the use case right now).
- realizing that this is the opposite operation of DROP EXPRESSION gave
me a clue about how to support partitioning/inheritance.
AT_DropExpression can be applied only to the whole inheritance tree at
once (see 8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated
discussion at https://postgr.es/m/2793383.1672944799@sss.pgh.pa.us),
it refuses to be applied to either the parent table ONLY, or directly
to partitions. This new command should work the same way.
- while researching the above, I stumbled upon a restriction of current
DROP EXPRESSION: it doesn't seem to be possible to apply it to
partition trees deeper than just one level (parent / child tables).
This is probably an oversight, but to avoid feature-creeping this
patch, I made the new command act the same way (see test case). I'll
try to address this separately.
- I added some note in the commit message to clarify why I added the new
command to AT_PASS_SET_EXPRESSION, since this wasn't clear enough in
my first mail/patch.
- I am not particularly attached to the syntax. Alternatives that would
come to mind would be:
SET GENERATED ALWAYS AS (expr) STORED
or to match the two existing commands:
ADD EXPRESSION (expr) STORED
As I said above, I think the explicit STORED is necessary. It would be
nice if the command would make it crystal clear to the user that it
implies rewriting the table, i.e. overwriting existing data. (To me,
all three forms are clear enough, especially considering that by this
point I would have already typed ALTER twice :-))
The attached v2 patches take care of the points above. They are again
split in two commits for ease of review.
Looking forward to any comment / feedback!
Alberto
PS: A note about the timing of this mail, as I am just getting
acquainted with all of this. I am aware that we're super short of a
feature freeze, and this thread is by no means an attempt to push for
this to go in now, nor to steal brain bandwidth from more important
active threads. I just thought it's OK to put the patches and the mails
out there as I make progress, even if it's just to bring this up and
revisit at a later point in time. Let me know if instead it would be
better to sit on my thoughts until a more appropriate time in the
release cycle.
--
Alberto Piai
Sensational AG
Zürich, Switzerland
Attachments:
[text/x-patch] v2-0001-Support-changing-a-column-into-a-stored-generated.patch (22.2K, ../../DHMSK551GIM8.1B1CN2JN8BK50@gmail.com/2-v2-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From be429e5b7e1ae7a84b564d04e6f126c62176c5cd Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Sun, 29 Mar 2026 21:45:44 +0200
Subject: [PATCH v2 1/2] Support changing a column into a stored generated
column
This adds an ALTER TABLE subcommand to turn a regular column
into a stored generated column:
... ALTER COLUMN c ADD GENERATED ALWAYS as (expr) STORED
The syntax is chosen to be similar to
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION. Phase 2 happens in the same pass as the former, in order to
run the cleanup code in ATPostAlterTypeCleanup, without which for
example we would not re-check constraints when rewriting the table.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
There is one limitation: currently DROP EXPRESSION does not allow to
change an inheritance tree of depth > 2. This seems like an oversight,
but in order to not feature-creep this commit, this is postponed for
later; it should then be fixed for both DROP EXPRESSION and this new
command.
This is mostly useful as a first step to be able to add a stored
generated column without rewriting the table under an exclusive lock.
For ease of review, the operation as of this commit always rewrites the
contents of the column using the new generated expression.
---
src/backend/commands/tablecmds.c | 187 +++++++++++++++++-
src/backend/parser/gram.y | 31 +++
src/include/nodes/parsenodes.h | 1 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 126 ++++++++++++
src/test/regress/sql/alter_table.sql | 76 +++++++
6 files changed, 423 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index c69c12dc014..66622bf4837 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -760,6 +760,14 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse,
+ bool recursing, LOCKMODE lockmode);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4743,6 +4751,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedAsExprStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5067,6 +5076,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedAsExprStored:
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGeneratedAsExprStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_SET_EXPRESSION;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5461,6 +5477,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedAsExprStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedAsExprStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6667,6 +6689,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedAsExprStored:
+ return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8823,6 +8847,165 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation for
+ *
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ *
+ * Checks whether recursion is allowed, following the same logic as ATPrepDropExpression.
+ */
+static void
+ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse,
+ bool recursing, LOCKMODE lockmode
+)
+{
+ /*
+ * Reject ONLY if there are child tables. See ATPrepDropExpression.
+ */
+ if (!recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ ObjectAddress address;
+ Expr *defval;
+ NewColumnValue *newval;
+ RawColumnDefault *rawEnt;
+ Relation pg_attribute;
+
+ Assert(def->raw_expr != NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ ReleaseSysCache(tuple);
+
+ /*
+ * Find everything that depends on the column (constraints, indexes, etc),
+ * and record enough information to let us recreate the objects.
+ */
+ RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+ rel, attnum, colName);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawEnt = palloc_object(RawColumnDefault);
+ rawEnt->attnum = attnum;
+ rawEnt->raw_default = def->raw_expr;
+ rawEnt->generated = def->generated_kind;
+ AddRelationNewConstraints(rel, list_make1(rawEnt), NIL,
+ false, true, false, NULL);
+
+ /* 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;
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
@@ -15290,7 +15473,9 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
SysScanDesc scan;
HeapTuple depTup;
- Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+ Assert(subtype == AT_AlterColumnType
+ || subtype == AT_SetExpression
+ || subtype == AT_AddGeneratedAsExprStored);
depRel = table_open(DependRelationId, RowExclusiveLock);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0fea726cdd5..315ee7d94e2 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2717,6 +2717,37 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->raw_expr = $9;
+ c->cooked_expr = NULL;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @5;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddGeneratedAsExprStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index df431220ac5..74958ef0dfa 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2506,6 +2506,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedAsExprStored, /* add generated always as (...) stored */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..3132ceac61f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedAsExprStored:
+ strtype = "ADD GENERATED ALWAYS AS (...) STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index ccd79dfecc0..2567d918ec3 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4863,3 +4863,129 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+ Table "testgen.t2"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | plain | |
+Check constraints:
+ "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+ "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+ did_rewrite_idx
+-----------------
+ t
+(1 row)
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+ERROR: column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default as (a * 2) stored;
+ ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR: syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+ ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+ ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+ERROR: generation expression is not immutable
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+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
+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 f5f13bbd3e7..76187083289 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,79 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+drop table testgen.t3;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+drop table testgen.t2;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 01d58d7e3ff3f7482ff478cb4a49c48aad276138
--
2.47.0
[text/x-patch] v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch (23.7K, ../../DHMSK551GIM8.1B1CN2JN8BK50@gmail.com/3-v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch)
download | inline diff:
From ada2e9aed8f03a177ec63c31e25fc474a281ee41 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Sun, 29 Mar 2026 21:45:50 +0200
Subject: [PATCH v2 2/2] Try to avoid a rewrite when adding a stored generated
column
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
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-04-24 09:10 ` Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-04-24 09:10 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Tue Apr 7, 2026 at 5:02 PM +08, Alberto Piai wrote:
> On Tue Mar 17, 2026 at 5:31 PM +07, Alberto Piai wrote:
>
>> I recently needed to add a stored generated column to a table of
>> nontrivial size, and realized that currently there is no way to do
>> that without rewriting the table under an AccessExclusiveLock.
Attached v3, just a rebase onto current master.
Regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
Attachments:
[text/x-patch] v3-0001-Support-changing-a-column-into-a-stored-generated.patch (22.3K, ../../DI19FP3X4VI0.21FO6V1HGJK5P@gmail.com/2-v3-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From 8131b2b35ae985bbc2ed8be410dccc583bf4171e Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 24 Apr 2026 10:44:49 +0200
Subject: [PATCH v3 1/2] Support changing a column into a stored generated
column
This adds an ALTER TABLE subcommand to turn a regular column
into a stored generated column:
... ALTER COLUMN c ADD GENERATED ALWAYS as (expr) STORED
The syntax is chosen to be similar to
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION. Phase 2 happens in the same pass as the former, in order to
run the cleanup code in ATPostAlterTypeCleanup, without which for
example we would not re-check constraints when rewriting the table.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
There is one limitation: currently DROP EXPRESSION does not allow to
change an inheritance tree of depth > 2. This seems like an oversight,
but in order to not feature-creep this commit, this is postponed for
later; it should then be fixed for both DROP EXPRESSION and this new
command.
This is mostly useful as a first step to be able to add a stored
generated column without rewriting the table under an exclusive lock.
For ease of review, the operation as of this commit always rewrites the
contents of the column using the new generated expression.
---
src/backend/commands/tablecmds.c | 186 +++++++++++++++++-
src/backend/parser/gram.y | 31 +++
src/include/nodes/parsenodes.h | 1 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 126 ++++++++++++
src/test/regress/sql/alter_table.sql | 76 +++++++
6 files changed, 422 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d8d7969bf30..aa54c629f8b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -783,6 +783,14 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4769,6 +4777,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedAsExprStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5093,6 +5102,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedAsExprStored:
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGeneratedAsExprStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_SET_EXPRESSION;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5487,6 +5503,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedAsExprStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedAsExprStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6695,6 +6717,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedAsExprStored:
+ return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8851,6 +8875,164 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation for
+ *
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ *
+ * Checks whether recursion is allowed, following the same logic as ATPrepDropExpression.
+ */
+static void
+ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables. See ATPrepDropExpression.
+ */
+ if (!recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ ObjectAddress address;
+ Expr *defval;
+ NewColumnValue *newval;
+ RawColumnDefault *rawEnt;
+ Relation pg_attribute;
+
+ Assert(def->raw_expr != NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ ReleaseSysCache(tuple);
+
+ /*
+ * Find everything that depends on the column (constraints, indexes, etc),
+ * and record enough information to let us recreate the objects.
+ */
+ RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+ rel, attnum, colName);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawEnt = palloc_object(RawColumnDefault);
+ rawEnt->attnum = attnum;
+ rawEnt->raw_default = def->raw_expr;
+ rawEnt->generated = def->generated_kind;
+ AddRelationNewConstraints(rel, list_make1(rawEnt), NIL,
+ false, true, false, NULL);
+
+ /* 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;
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
@@ -15320,7 +15502,9 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
SysScanDesc scan;
HeapTuple depTup;
- Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+ Assert(subtype == AT_AlterColumnType
+ || subtype == AT_SetExpression
+ || subtype == AT_AddGeneratedAsExprStored);
depRel = table_open(DependRelationId, RowExclusiveLock);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..8cd75572257 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,37 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->raw_expr = $9;
+ c->cooked_expr = NULL;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @5;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddGeneratedAsExprStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..620e2dd73bf 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2527,6 +2527,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedAsExprStored, /* add generated always as (...) stored */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..3132ceac61f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedAsExprStored:
+ strtype = "ADD GENERATED ALWAYS AS (...) STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 6dd22be0e8d..08981f4e380 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4875,3 +4875,129 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+ Table "testgen.t2"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | plain | |
+Check constraints:
+ "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+ "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+ did_rewrite_idx
+-----------------
+ t
+(1 row)
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+ERROR: column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default as (a * 2) stored;
+ ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR: syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+ ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+ ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+ERROR: generation expression is not immutable
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+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
+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 f5f13bbd3e7..76187083289 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,79 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+drop table testgen.t3;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always as (bar * 2) stored;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+drop table testgen.t2;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 3b28dad70e2fa57a973697d51242c284d475c7df
--
2.47.0
[text/x-patch] v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch (23.7K, ../../DI19FP3X4VI0.21FO6V1HGJK5P@gmail.com/3-v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch)
download | inline diff:
From 74d905c0dfd8e0df93c1689dc27cf2bf3003fa10 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 24 Apr 2026 10:44:58 +0200
Subject: [PATCH v3 2/2] Try to avoid a rewrite when adding a stored generated
column
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
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-05-14 22:46 ` Alberto Piai <alberto.piai@gmail.com>
2026-05-26 15:23 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 2 replies; 37+ messages in thread
From: Alberto Piai @ 2026-05-14 22:46 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Fri Apr 24, 2026 at 2:10 AM PDT, Alberto Piai wrote:
> On Tue Apr 7, 2026 at 5:02 PM +08, Alberto Piai wrote:
>> On Tue Mar 17, 2026 at 5:31 PM +07, Alberto Piai wrote:
>>
>>> I recently needed to add a stored generated column to a table of
>>> nontrivial size, and realized that currently there is no way to do
>>> that without rewriting the table under an AccessExclusiveLock.
The attached v4 is a rebase against current master, plus:
- I moved the call to RememberAllDependentForRebuilding before the
update to pg_attribute, since it provides checks for some
invalid/unsupported invocations it seems more useful to do it before
changing anything.
- I hadn't noticed before that AddRelationNewConstraints returns the new
(cooked) default definitions, so we can use those instead of building
them again.
- cleaned up some includes I had added by mistake, and moved some tests
around between the two commits
A while back I also posted a fix for the issue of DROP EXPRESSION not
working with subpartitions [1], this patch isn't ajusted yet to match, I
would do that if the bugfix would be committed first.
I am still hoping to get a reviewer for the in-person commitfest at the
upcoming pgconf.dev :)
It's my first contribution, but the change is pretty self-contained and
hopefully not terribly complex to review. I'm trying to address a real
world use case, it would be fantastic to make some progress with this
patch.
Anyone's motivated? :)
Regards,
Alberto
[1] https://www.postgresql.org/message-id/DHMT78XOD8BK.341V3H87KZ7NO%40gmail.com
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 9f7025c93c8b979e0e01b8d7dd4db2b0272eadd6 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Thu, 14 May 2026 14:51:24 -0700
Subject: [PATCH v4 1/2] Support changing a column into a stored generated
column
This adds an ALTER TABLE subcommand to turn a regular column
into a stored generated column:
... ALTER COLUMN c ADD GENERATED ALWAYS as (expr) STORED
The syntax is chosen to be similar to
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION. Phase 2 happens in the same pass as the former, in order to
run the cleanup code in ATPostAlterTypeCleanup, without which for
example we would not re-check constraints when rewriting the table.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
There is one limitation: currently DROP EXPRESSION does not allow to
change an inheritance tree of depth > 2. This seems like an oversight,
but in order to not feature-creep this commit, this is postponed for
later; it should then be fixed for both DROP EXPRESSION and this new
command.
This is mostly useful as a first step to be able to add a stored
generated column without rewriting the table under an exclusive lock.
For ease of review, the operation as of this commit always rewrites the
contents of the column using the new generated expression.
---
src/backend/commands/tablecmds.c | 194 ++++++++++++++++-
src/backend/parser/gram.y | 31 +++
src/include/nodes/parsenodes.h | 1 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 200 ++++++++++++++++++
src/test/regress/sql/alter_table.sql | 142 +++++++++++++
6 files changed, 570 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 92b0f38c353..39faef0a114 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -783,6 +783,14 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4769,6 +4777,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedAsExprStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5093,6 +5102,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedAsExprStored:
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGeneratedAsExprStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_SET_EXPRESSION;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5487,6 +5503,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedAsExprStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedAsExprStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6695,6 +6717,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedAsExprStored:
+ return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8851,6 +8875,172 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation for
+ *
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ *
+ * Checks whether recursion is allowed, following the same logic as ATPrepDropExpression.
+ */
+static void
+ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables. See ATPrepDropExpression.
+ */
+ if (!recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ ObjectAddress address;
+ NewColumnValue *newval;
+ RawColumnDefault *rawEnt;
+ Relation pg_attribute;
+ List *newcons;
+ CookedConstraint *cookedDef;
+
+ Assert(def->raw_expr != NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * Find everything that depends on the column (constraints, indexes, etc),
+ * and record enough information to let us recreate the objects.
+ */
+ RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+ rel, attnum, colName);
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ ReleaseSysCache(tuple);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawEnt = palloc_object(RawColumnDefault);
+ rawEnt->attnum = attnum;
+ rawEnt->raw_default = def->raw_expr;
+ rawEnt->generated = def->generated_kind;
+ newcons = AddRelationNewConstraints(rel, list_make1(rawEnt),
+ NIL, false, true, false, NULL);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /*
+ * At the moment, AddRelationNewConstraints always returns one element
+ * when called with a generated = STORED input, but guard against
+ * accessing an empty list anyway.
+ */
+ if (list_length(newcons) < 1)
+ ereport(ERROR,
+ errmsg_internal("expected exactly one processed default value"));
+
+ cookedDef = linitial(newcons);
+
+ /*
+ * Clear all the missing values if we're rewriting the table, 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 = (Expr *) cookedDef->expr;
+ newval->is_generated = true;
+ tab->newvals = lappend(tab->newvals, newval);
+ tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
@@ -15320,7 +15510,9 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
SysScanDesc scan;
HeapTuple depTup;
- Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+ Assert(subtype == AT_AlterColumnType
+ || subtype == AT_SetExpression
+ || subtype == AT_AddGeneratedAsExprStored);
depRel = table_open(DependRelationId, RowExclusiveLock);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..8cd75572257 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,37 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->raw_expr = $9;
+ c->cooked_expr = NULL;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @5;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddGeneratedAsExprStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..620e2dd73bf 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2527,6 +2527,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedAsExprStored, /* add generated always as (...) stored */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..3132ceac61f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedAsExprStored:
+ strtype = "ADD GENERATED ALWAYS AS (...) STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 6dd22be0e8d..2d0ce414d12 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4875,3 +4875,203 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+ Table "testgen.t2"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | plain | |
+Check constraints:
+ "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+ "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+ did_rewrite_idx
+-----------------
+ t
+(1 row)
+
+drop table testgen.t3;
+-- test support for partitioned 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;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+ERROR: column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default as (a * 2) stored;
+ ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR: syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+ ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+ ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+ERROR: generation expression is not immutable
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+ERROR: cannot use subquery in column generation expression
+drop table testgen.t3;
+drop schema testgen cascade;
+NOTICE: 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 f5f13bbd3e7..51d818d4995 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,145 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+drop table testgen.t3;
+
+-- test support for partitioned 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;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+drop table testgen.t2;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 6b48f5d1a74168c78badfb2e59ef788bb8eb396e
--
2.47.0
From bec0bdcd6d49b4ade505fb257a32b7aa7700dac0 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Thu, 14 May 2026 14:51:33 -0700
Subject: [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated
column
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
Attachments:
[text/plain] v4-0001-Support-changing-a-column-into-a-stored-generated.patch (27.8K, ../../DIIRFCWJIA80.2Q5DYIX6C7KZ5@gmail.com/2-v4-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From 9f7025c93c8b979e0e01b8d7dd4db2b0272eadd6 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Thu, 14 May 2026 14:51:24 -0700
Subject: [PATCH v4 1/2] Support changing a column into a stored generated
column
This adds an ALTER TABLE subcommand to turn a regular column
into a stored generated column:
... ALTER COLUMN c ADD GENERATED ALWAYS as (expr) STORED
The syntax is chosen to be similar to
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION. Phase 2 happens in the same pass as the former, in order to
run the cleanup code in ATPostAlterTypeCleanup, without which for
example we would not re-check constraints when rewriting the table.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
There is one limitation: currently DROP EXPRESSION does not allow to
change an inheritance tree of depth > 2. This seems like an oversight,
but in order to not feature-creep this commit, this is postponed for
later; it should then be fixed for both DROP EXPRESSION and this new
command.
This is mostly useful as a first step to be able to add a stored
generated column without rewriting the table under an exclusive lock.
For ease of review, the operation as of this commit always rewrites the
contents of the column using the new generated expression.
---
src/backend/commands/tablecmds.c | 194 ++++++++++++++++-
src/backend/parser/gram.y | 31 +++
src/include/nodes/parsenodes.h | 1 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 200 ++++++++++++++++++
src/test/regress/sql/alter_table.sql | 142 +++++++++++++
6 files changed, 570 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 92b0f38c353..39faef0a114 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -783,6 +783,14 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4769,6 +4777,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedAsExprStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5093,6 +5102,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedAsExprStored:
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGeneratedAsExprStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_SET_EXPRESSION;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5487,6 +5503,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedAsExprStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedAsExprStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6695,6 +6717,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedAsExprStored:
+ return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8851,6 +8875,172 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation for
+ *
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ *
+ * Checks whether recursion is allowed, following the same logic as ATPrepDropExpression.
+ */
+static void
+ATPrepAddGeneratedAsExprStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables. See ATPrepDropExpression.
+ */
+ if (!recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER TABLE / ADD GENERATED ALWAYS AS (expr) STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ ObjectAddress address;
+ NewColumnValue *newval;
+ RawColumnDefault *rawEnt;
+ Relation pg_attribute;
+ List *newcons;
+ CookedConstraint *cookedDef;
+
+ Assert(def->raw_expr != NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * Find everything that depends on the column (constraints, indexes, etc),
+ * and record enough information to let us recreate the objects.
+ */
+ RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+ rel, attnum, colName);
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ ReleaseSysCache(tuple);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawEnt = palloc_object(RawColumnDefault);
+ rawEnt->attnum = attnum;
+ rawEnt->raw_default = def->raw_expr;
+ rawEnt->generated = def->generated_kind;
+ newcons = AddRelationNewConstraints(rel, list_make1(rawEnt),
+ NIL, false, true, false, NULL);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /*
+ * At the moment, AddRelationNewConstraints always returns one element
+ * when called with a generated = STORED input, but guard against
+ * accessing an empty list anyway.
+ */
+ if (list_length(newcons) < 1)
+ ereport(ERROR,
+ errmsg_internal("expected exactly one processed default value"));
+
+ cookedDef = linitial(newcons);
+
+ /*
+ * Clear all the missing values if we're rewriting the table, 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 = (Expr *) cookedDef->expr;
+ newval->is_generated = true;
+ tab->newvals = lappend(tab->newvals, newval);
+ tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
@@ -15320,7 +15510,9 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
SysScanDesc scan;
HeapTuple depTup;
- Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+ Assert(subtype == AT_AlterColumnType
+ || subtype == AT_SetExpression
+ || subtype == AT_AddGeneratedAsExprStored);
depRel = table_open(DependRelationId, RowExclusiveLock);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..8cd75572257 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,37 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->raw_expr = $9;
+ c->cooked_expr = NULL;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @5;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddGeneratedAsExprStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..620e2dd73bf 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2527,6 +2527,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedAsExprStored, /* add generated always as (...) stored */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..3132ceac61f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedAsExprStored:
+ strtype = "ADD GENERATED ALWAYS AS (...) STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 6dd22be0e8d..2d0ce414d12 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4875,3 +4875,203 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+ Table "testgen.t2"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | plain | |
+Check constraints:
+ "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+ "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+ did_rewrite_idx
+-----------------
+ t
+(1 row)
+
+drop table testgen.t3;
+-- test support for partitioned 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;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+ERROR: column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default as (a * 2) stored;
+ ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR: syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+ ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+ ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+ERROR: generation expression is not immutable
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+ERROR: cannot use subquery in column generation expression
+drop table testgen.t3;
+drop schema testgen cascade;
+NOTICE: 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 f5f13bbd3e7..51d818d4995 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,145 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+ select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+ add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+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;
+drop table testgen.t3;
+
+-- test support for partitioned 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;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+ add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+drop table testgen.t2;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 alter column b
+ add generated always as (a + random()) stored;
+-- invalid: expr cannot use subselects
+alter table testgen.t3 alter column b
+ add generated always as (a + (select 1)) stored;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 6b48f5d1a74168c78badfb2e59ef788bb8eb396e
--
2.47.0
[text/plain] v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch (17.9K, ../../DIIRFCWJIA80.2Q5DYIX6C7KZ5@gmail.com/3-v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch)
download | inline diff:
From bec0bdcd6d49b4ade505fb257a32b7aa7700dac0 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Thu, 14 May 2026 14:51:33 -0700
Subject: [PATCH v4 2/2] Try to avoid a rewrite when adding a stored generated
column
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
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-05-26 15:23 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-05-27 17:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
1 sibling, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-05-26 15:23 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Thu, 2026-05-14 at 15:46 -0700, Alberto Piai wrote:
> > > On Tue Mar 17, 2026 at 5:31 PM +07, Alberto Piai wrote:
> > >
> > > > I recently needed to add a stored generated column to a table of
> > > > nontrivial size, and realized that currently there is no way to do
> > > > that without rewriting the table under an AccessExclusiveLock.
>
> The attached v4 is a rebase against current master
I understand the need that the patch fulfills, and I agree that it would be a
nice feature.
I have a few thoughts about this that don't concern the implementation:
1) The SQL standard knows ALTER TABLE ... ADD ... GENERATED ALWAYS AS (...)
and ALTER TABLE ... ALTER ... DROP EXPRESSION, but there is no provision
for ALTER TABLE ... ADD GENERATED ALWAYS AS (...).
So this patch adds non-standard syntax that may one day conflict with
a new version of the standard. I think we can still do it, and the
proposed syntax looks right, but I thought I should mention it.
2) We currently have ALTER TABLE ... ALTER ... SET EXPRESSION AS (...) to
change the generation expression of a column. This command always
rewrites the table, according to the documentation.
I think that if the present patch adds support to skip rewriting the table
when a generation expression is added and the expression matches a check
constraint, changing the generation expression should also be possible
without a rewrite. If not, I would consider that a violation of the
principle of least astonishment.
Would it be difficult to extend the patch to support that?
3) We already have a couple of tricks to avoid blocking for a long time:
- ALTER TABLE ... ALTER ... SET NOT NULL can skip the table scan if there
is a check constraint that makes sure that the column is NOT NULL
- ALTER TABLE ... ATTACH PARTITION can skip the scan of the new partition
if there is a check constraint matching the partition constraint
It would be great to document these little tricks in the documentation,
probably on the ALTER TABLE page. This is not necessarily the job of
this patch, but it would also not be off-topic for the patch.
Comments on the patch:
----------------------
The patch applies and builds cleanly and passes the regression tests.
Missing parts:
- There is no documentation. At least ALTER TABLE needs a description of the
new syntax, and would ideally mention the trick with the check constraint.
- There should be support for command line completion for the new syntax.
Bugs:
- The patch doesn't test if the column is an identity column:
CREATE TABLE tab (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
INSERT INTO tab VALUES (DEFAULT);
ALTER TABLE tab ALTER id ADD GENERATED ALWAYS AS (1) STORED;
The ALTER TABLE should fail, but doesn't.
- Strange behavior with sequences owned by the column:
CREATE TABLE tab (id bigserial);
INSERT INTO tab VALUES (DEFAULT);
ALTER TABLE tab ALTER id ADD GENERATED ALWAYS AS (1) STORED;
\ds tab_id_seq
List of sequences
Schema | Name | Type | Owner
--------+------------+----------+----------
public | tab_id_seq | sequence | postgres
(1 row)
I think that any sequence owned by the column should be dropped.
Alternatively, you could throw an error.
- Incorrect handling of NULL values:
CREATE TABLE tab (col1 integer, col2 integer);
INSERT INTO tab VALUES (2, NULL);
-- works, because NULL results from the check are accepted
ALTER TABLE tab ADD CHECK (col2 = col1);
SELECT pg_relation_filenode('tab');
pg_relation_filenode
----------------------
19920
(1 row)
ALTER TABLE tab ALTER col2 ADD GENERATED ALWAYS AS (col1) STORED;
SELECT pg_relation_filenode('tab');
pg_relation_filenode
----------------------
19920
(1 row)
TABLE tab;
col1 | col2
------+------
2 | ∅
(1 row)
I am not sure what the correct approach would be. The simple approach would be
to only skip the rewrite if the column has a NOT NULL constraint or an equivalent
check constraint, but perhaps you can think of a way to do better.
Comments on the code:
> --- a/src/backend/commands/tablecmds.c
> +++ b/src/backend/commands/tablecmds.c
> @@ -5093,6 +5102,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
> ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
> pass = AT_PASS_SET_EXPRESSION;
> break;
> + case AT_AddGeneratedAsExprStored:
You should add a comment, same as for the other branches.
> @@ -6695,6 +6717,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
> return "ALTER COLUMN ... SET NOT NULL";
> case AT_SetExpression:
> return "ALTER COLUMN ... SET EXPRESSION";
> + case AT_AddGeneratedAsExprStored:
> + return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
Keep it short, like "ALTER COLUMN ... ADD GENERATED".
> --- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
> +++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
> @@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
> case AT_SetNotNull:
> strtype = "SET NOT NULL";
> break;
> + case AT_AddGeneratedAsExprStored:
> + strtype = "ADD GENERATED ALWAYS AS (...) STORED";
> + break;
I suggest "ALTER COLUMN ADD GENERATED ALWAYS AS", but I won't insist.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-26 15:23 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-05-27 17:44 ` Alberto Piai <alberto.piai@gmail.com>
2026-06-15 20:41 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-05-27 17:44 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Tue May 26, 2026 at 5:23 PM CEST, Laurenz Albe wrote:
>
> 1) The SQL standard knows ALTER TABLE ... ADD ... GENERATED ALWAYS AS (...)
> and ALTER TABLE ... ALTER ... DROP EXPRESSION, but there is no provision
> for ALTER TABLE ... ADD GENERATED ALWAYS AS (...).
> So this patch adds non-standard syntax that may one day conflict with
> a new version of the standard. I think we can still do it, and the
> proposed syntax looks right, but I thought I should mention it.
Thank you for bringing this up. I don't have access to the standard, but
the chance of a possible conflict with future editions was at the back
of my mind. I don't see a way to exclude it completely. In a sibling
mail in this thread (you should be in CC), I have made a new iteration
on this proposal, which also tries to make the command more specific to
avoid future conflicts.
> 2) We currently have ALTER TABLE ... ALTER ... SET EXPRESSION AS (...) to
> change the generation expression of a column. This command always
> rewrites the table, according to the documentation.
> I think that if the present patch adds support to skip rewriting the table
> when a generation expression is added and the expression matches a check
> constraint, changing the generation expression should also be possible
> without a rewrite. If not, I would consider that a violation of the
> principle of least astonishment.
> Would it be difficult to extend the patch to support that?
Yes, I don't see a way to make that work. Since we're talking only about
stored values, a rewrite will always be necessary. However, using this
new command, a user could add a column with the new expression, then
atomically drop the old one and rename. All without holding onto an
AccessExclusiveLock for a long time :)
> 3) We already have a couple of tricks to avoid blocking for a long time:
>
> - ALTER TABLE ... ALTER ... SET NOT NULL can skip the table scan if there
> is a check constraint that makes sure that the column is NOT NULL
>
> - ALTER TABLE ... ATTACH PARTITION can skip the scan of the new partition
> if there is a check constraint matching the partition constraint
>
> It would be great to document these little tricks in the documentation,
> probably on the ALTER TABLE page. This is not necessarily the job of
> this patch, but it would also not be off-topic for the patch.
The SET NOT NULL one and the ATTACH PARTITION one are documented in the
section specific to the command. However
or, if an equivalent index already exists, it will be attached to the
target table's index, as if ALTER INDEX ATTACH PARTITION had been
executed
is not very explicit about the advantages this has for online
migrations.
In the NOTES section of the ALTER TABLE page, there is a paragraph about
NOT VALID / VALIDATE, which is another operation in the same spirit as
this.
Maybe we could group them all in a new section dedicated to online
schema migrations?
(However, even if it's definitely on-topic with this patch, I would work
on this in a separate patch / email thread.)
> Missing parts:
>
> - There is no documentation. At least ALTER TABLE needs a description of the
> new syntax, and would ideally mention the trick with the check constraint.
Yes, I will work on this next. I also believe it's a great way to show a
feature, even early during development. I just wanted to avoid doing it
_too_ early, before having had any feedback about the idea.
> - There should be support for command line completion for the new syntax.
Great idea, I'll add this too.
> Bugs:
>
> - The patch doesn't test if the column is an identity column:
>
> CREATE TABLE tab (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
> INSERT INTO tab VALUES (DEFAULT);
> ALTER TABLE tab ALTER id ADD GENERATED ALWAYS AS (1) STORED;
>
> The ALTER TABLE should fail, but doesn't.
>
> - Strange behavior with sequences owned by the column:
>
> CREATE TABLE tab (id bigserial);
> INSERT INTO tab VALUES (DEFAULT);
> ALTER TABLE tab ALTER id ADD GENERATED ALWAYS AS (1) STORED;
> \ds tab_id_seq
> List of sequences
> Schema | Name | Type | Owner
> --------+------------+----------+----------
> public | tab_id_seq | sequence | postgres
> (1 row)
>
> I think that any sequence owned by the column should be dropped.
> Alternatively, you could throw an error.
Thanks for testing this!
I have reused RememberAllDependentForRebuilding() which does some
validation, but was originally meant for ALTER COLUMN TYPE. I will add
checks and tests for these cases, but to be consistent with how the
other dependencies are handled, I think it's better to throw an error
here (this is what happens for example if trying to ALTER TYPE of a
column used by a function).
>
> - Incorrect handling of NULL values:
See sibling mail, in the next iteration the constraint will have to use
IS NOT DISTINCT FROM. I think that should cover all cases.
>
> Comments on the code:
>
>> --- a/src/backend/commands/tablecmds.c
>> +++ b/src/backend/commands/tablecmds.c
>> @@ -5093,6 +5102,13 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
>> ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
>> pass = AT_PASS_SET_EXPRESSION;
>> break;
>> + case AT_AddGeneratedAsExprStored:
>
> You should add a comment, same as for the other branches.
>
>> @@ -6695,6 +6717,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
>> return "ALTER COLUMN ... SET NOT NULL";
>> case AT_SetExpression:
>> return "ALTER COLUMN ... SET EXPRESSION";
>> + case AT_AddGeneratedAsExprStored:
>> + return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
>
> Keep it short, like "ALTER COLUMN ... ADD GENERATED".
>
>> --- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
>> +++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
>> @@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
>> case AT_SetNotNull:
>> strtype = "SET NOT NULL";
>> break;
>> + case AT_AddGeneratedAsExprStored:
>> + strtype = "ADD GENERATED ALWAYS AS (...) STORED";
>> + break;
>
> I suggest "ALTER COLUMN ADD GENERATED ALWAYS AS", but I won't insist.
Agreed, will fix all these in the next version of the patch.
Thank you again for the review!
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-26 15:23 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-05-27 17:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-06-15 20:41 ` Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 0 replies; 37+ messages in thread
From: Laurenz Albe @ 2026-06-15 20:41 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Wed, 2026-05-27 at 19:44 +0200, Alberto Piai wrote:
> On Tue May 26, 2026 at 5:23 PM CEST, Laurenz Albe wrote:
>
>
> > 2) We currently have ALTER TABLE ... ALTER ... SET EXPRESSION AS (...) to
> > change the generation expression of a column. This command always
> > rewrites the table, according to the documentation.
> > I think that if the present patch adds support to skip rewriting the table
> > when a generation expression is added and the expression matches a check
> > constraint, changing the generation expression should also be possible
> > without a rewrite. If not, I would consider that a violation of the
> > principle of least astonishment.
> > Would it be difficult to extend the patch to support that?
>
> Yes, I don't see a way to make that work. Since we're talking only about
> stored values, a rewrite will always be necessary. However, using this
> new command, a user could add a column with the new expression, then
> atomically drop the old one and rename. All without holding onto an
> AccessExclusiveLock for a long time :)
With your new proposal to never rewrite the table, but fail instead if
there is no constraint, my objection loses its point, so I withdraw it.
> > 3) We already have a couple of tricks to avoid blocking for a long time:
> >
> > - ALTER TABLE ... ALTER ... SET NOT NULL can skip the table scan if there
> > is a check constraint that makes sure that the column is NOT NULL
> >
> > - ALTER TABLE ... ATTACH PARTITION can skip the scan of the new partition
> > if there is a check constraint matching the partition constraint
> >
> > It would be great to document these little tricks in the documentation,
> > probably on the ALTER TABLE page. This is not necessarily the job of
> > this patch, but it would also not be off-topic for the patch.
>
> The SET NOT NULL one and the ATTACH PARTITION one are documented in the
> section specific to the command. However
>
> or, if an equivalent index already exists, it will be attached to the
> target table's index, as if ALTER INDEX ATTACH PARTITION had been
> executed
>
> is not very explicit about the advantages this has for online
> migrations.
>
> In the NOTES section of the ALTER TABLE page, there is a paragraph about
> NOT VALID / VALIDATE, which is another operation in the same spirit as
> this.
>
> Maybe we could group them all in a new section dedicated to online
> schema migrations?
You are right, the existing shortcuts are documented. Your new proposal
makes the proposed feature different from these existing cases, so I don't
think lumping them together is a good idea now.
> Agreed, will fix all these in the next version of the patch.
Great; I'm looking forward to it.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-05-27 17:43 ` Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
1 sibling, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-05-27 17:43 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>; Laurenz Albe <laurenz.albe@cybertec.at>
Hi,
On Fri May 15, 2026 at 12:46 AM CEST, Alberto Piai wrote:
> On Fri Apr 24, 2026 at 2:10 AM PDT, Alberto Piai wrote:
>> On Tue Apr 7, 2026 at 5:02 PM +08, Alberto Piai wrote:
>>> On Tue Mar 17, 2026 at 5:31 PM +07, Alberto Piai wrote:
>>>
>>>> I recently needed to add a stored generated column to a table of
>>>> nontrivial size, and realized that currently there is no way to do
>>>> that without rewriting the table under an AccessExclusiveLock.
here's a not-so-brief summary of the conversations around this topic at
pgconf.dev, and a new proposal at the end.
I had the chance to bring this up with other attendees, and many
recognized the use case as a useful one, addressing a real operational
issue.
In particular, I had great feedback from Staš Kotarac Guček, who pointed
out a major flaw in my current proposal: a constraint of the form
CHECK (c = expr)
would not work correctly when expr evaluates to null for some rows.
Thank you Staš, in the next iteration I will change the constraint to
use IS NOT DISTINCT FROM, instead.
I briefly mentioned this topic to Tom Lane, who quickly replied with the
question: should this not fail when it can't use the constraint, instead
of overwriting the contents of the column?
Thanks Tom, I will get to this later in this mail.
I had registered this patch for the in-person commitfest at pgconf.dev,
and Álvaro Herrera picked it up for review. Thank you Álvaro, and thank
you Peter for organizing the event.
We managed to find some time on the very last day of the conference, and
went through the current design and code. The open items (which I will
address in the next iteration of this patch) are:
* missing user documentation
I will work on this next. I think it's a good way to explain the
feature even early during development. I just didn't want to do it
_too_ early, without having had any feedback.
* try to minimize command counter increments
There might be one call to CommandCounterIncrement() which is not
necessary, I'll try remove it.
* comment on why it is necessary to clear missing values when rewriting
the table
ATExecAlterColumnType() and ATExecSetExpression() both do this
explicitly when requesting a table rewrite. I'll extend the comment,
and also look into whether this is something that should be done any
time a table rewrite happens. In that case, it might be worth moving
this into the rewriting code rather than having each caller do it.
* interactions with other subcommands in the same alter table statement
My reasoning regarding this was: if I do this in
AT_PASS_SET_EXPRESSION, it should be safe. I will invest some more
time into this and add tests, too.
We also looked at the overall design of the new command, and we agreed
that it is a fitting addition to our current SET EXPRESSION and DROP
EXPRESSION. Regarding the question of whether it should be SET or ADD,
we agreed that ADD (i.e. the current proposal) is clearer, especially
for its similarity to ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY.
Regarding the question of "should this fail or rewrite the table when a
usable constraint isn't found": Álvaro's suggestion here was to use a
more ad-hoc command, meant more specifically for this use case of
converting into a stored generated column without rewriting it. If the
command would be dedicated specifically to this, it would make sense to
have it fail when a usable constraint isn't found.
Last but not least, I also discussed this with Laurenz Albe, and he
wrote a very useful review in this thread. I will address that
separately and reply directly to that mail, but one point I can already
merge in this discussion is about the syntax of the command:
> 1) The SQL standard knows ALTER TABLE ... ADD ... GENERATED ALWAYS AS (...)
> and ALTER TABLE ... ALTER ... DROP EXPRESSION, but there is no provision
> for ALTER TABLE ... ADD GENERATED ALWAYS AS (...).
> So this patch adds non-standard syntax that may one day conflict with
> a new version of the standard. I think we can still do it, and the
> proposed syntax looks right, but I thought I should mention it.
I'd like to take his point, together with the question from Tom and the
suggestion by Álvaro, and make a new proposal for the design of this
command.
Design iteration 2
------------------
Syntax:
ALTER TABLE t ALTER COLUMN c
ADD GENERATED ALWAYS AS (expr) STORED USING CONSTRAINT check_name
check_name must be a valid constraint of the form
CHECK (c IS NOT DISTINCT FROM (expr))
This fails if:
- a check constraint named check_name is not found for table c
- the constraint is not valid
- the constraint does not match exactly the expr the user intends to use
as a stored default expression
On success, the table c is now a stored generated column with the given
default expression, and the check_name constraint has been removed.
This addresses Tom's remark, we can now fail instead of just rewriting
the column.
It improves slightly upon the issue of a potential conflict with a
future edition of the SQL standard, by being more specific. I don't see
a way to be completely sure we won't have conflicts. We could improve
more by making the syntax more "alien" and very unlinkely to be picked
up by the standard, but at a usability cost for Postgres. I'm open to
suggestions.
It improves upon another question raised by Álvaro: does the user have
to clean up the constraint? In v1 I felt it was better to have the user
remove it after the migration. Since here it's explicitly mentioned as
the constraint to use to migrate the column, I think it's OK to remove
it. We are conceptually moving it from being a constraint to being the
new default expression.
The implementation should also be simpler, since there will never be a
table rewrite.
Any thoughts about this?
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-06-15 18:38 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-06-15 18:38 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Wed, 2026-05-27 at 19:43 +0200, Alberto Piai wrote:
> I'd like to take his point, together with the question from Tom and the
> suggestion by Álvaro, and make a new proposal for the design of this
> command.
>
> Design iteration 2
> ------------------
>
> Syntax:
>
> ALTER TABLE t ALTER COLUMN c
> ADD GENERATED ALWAYS AS (expr) STORED USING CONSTRAINT check_name
>
> check_name must be a valid constraint of the form
>
> CHECK (c IS NOT DISTINCT FROM (expr))
>
> This fails if:
>
> - a check constraint named check_name is not found for table c
> - the constraint is not valid
> - the constraint does not match exactly the expr the user intends to use
> as a stored default expression
>
> On success, the table c is now a stored generated column with the given
> default expression, and the check_name constraint has been removed.
>
> This addresses Tom's remark, we can now fail instead of just rewriting
> the column.
I like this proposal. It avoids the question "to rewrite or not to
rewrite" by just outright failing if there is no suitable constraint.
The idea to avoid the problem with NULL by forcing IS NOT DISTINCT FROM
in the constraint is a good solution. Perhaps you could also allow
the equality operator if the column in question is defined NOT NULL.
> It improves slightly upon the issue of a potential conflict with a
> future edition of the SQL standard, by being more specific. I don't see
> a way to be completely sure we won't have conflicts. We could improve
> more by making the syntax more "alien" and very unlinkely to be picked
> up by the standard, but at a usability cost for Postgres. I'm open to
> suggestions.
I don't think that the new proposal makes it less likely to get in
conflict with later additions to the standard. But I don't think that
inventing unlikely syntax to avoid such conflicts is a good idea.
First, the standard committee itself seems (or seemed) to have a strong
predilection for alien, verbose syntax.
Second, if we end up with weird, unwieldy syntax, that would be bad.
No, the syntax you are proposing sounds reasonable to me.
> It improves upon another question raised by Álvaro: does the user have
> to clean up the constraint? In v1 I felt it was better to have the user
> remove it after the migration. Since here it's explicitly mentioned as
> the constraint to use to migrate the column, I think it's OK to remove
> it. We are conceptually moving it from being a constraint to being the
> new default expression.
>
> The implementation should also be simpler, since there will never be a
> table rewrite.
>
> Any thoughts about this?
Yes. I think that you should not drop the constraint. That's what I'd
expect, similar to how we don't drop the check constraint that allows
to skip the table scan in ALTER TABLE ... ALTER COLUMN ... SET NOT NULL
or ALTER TABLE ... ATTACK PARTITION.
I feel that automatically dropping the constraint is a bit too much
black magic, but it is more a feeling than a conviction.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-06-30 13:44 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-06-30 13:44 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Mon Jun 15, 2026 at 8:38 PM CEST, Laurenz Albe wrote:
> On Wed, 2026-05-27 at 19:43 +0200, Alberto Piai wrote:
>> Design iteration 2
>> ------------------
>
> I like this proposal. It avoids the question "to rewrite or not to
> rewrite" by just outright failing if there is no suitable constraint.
>
> The idea to avoid the problem with NULL by forcing IS NOT DISTINCT FROM
> in the constraint is a good solution. Perhaps you could also allow
> the equality operator if the column in question is defined NOT NULL.
In the attached v5 patch I've implemented this design, and went one step
further (let me know what you think). While discussing this with my
colleagues at work, the question came up (thanks, Philip!): now that we
mention the constraint explicitly, what's the point of repeating the
expression too? The constraint already defines an equality to an
expression. I think this is a very good point, and it removes one
further way in which the operation could fail, so I went ahead and
changed the command to not mention the expression. It takes the
expression defined in the constraint and uses _that_ as the generator
expression of the column.
Design iteration 3
------------------
Syntax:
ALTER TABLE t ALTER COLUMN c
ADD GENERATED ALWAYS STORED USING CONSTRAINT check_name
check_name must be a valid constraint of a specific shape. If the
column is nullable:
CHECK (c IS NOT DISTINCT FROM expr)
If the column is NOT NULL, either of the following is acceptable:
CHECK (c = expr)
CHECK (c IS NOT DISTINCT FROM expr)
The column is then changed to be a stored generated column, with the
"expr" from the constraint as its generator expression.
>> Any thoughts about this?
>
> Yes. I think that you should not drop the constraint. That's what I'd
> expect, similar to how we don't drop the check constraint that allows
> to skip the table scan in ALTER TABLE ... ALTER COLUMN ... SET NOT
> NULL or ALTER TABLE ... ATTACK PARTITION.
>
> I feel that automatically dropping the constraint is a bit too much
> black magic, but it is more a feeling than a conviction.
I don't have a strong opinion on whether to cleanup or not, I'll gladly
take your input. This version of the patch does not drop the constraint
anymore.
This version addresses your inputs from the last review:
- I added documentation for the new alter table form to alter_table.sgml
- Tab completion for psql is there now
- The missing error conditions in case of an identity column or
sequences are now handled, more about this in the next section.
Failure conditions
------------------
There's quite a few invalid states that cannot be reached via CREATE
TABLE and should not be reachable via ALTER TABLE either.
The following are detected and fail the operation:
- the column is already a generated column
- the column is an identity column
- the column is referenced by a sequence (it is most likely a serial
column)
- the column is referenced by another column's default expression
- the column references another generated column
- the column is referenced in a partition key, either directly or
through a whole row expression
- the new default expression is not immutable
Additionally, we of course bail if the constraint is not found, not
valid, not enforced or doesn't match the specific structure we need.
Another case I considered is the column being referenced in the body of
a pre-parsed function (BEGIN ATOMIC SQL functions). In this case though,
it seems to me that we don't need to fail here: we are not altering the
type of the column, and when reading a stored generated column there's
no expression replacement happening (as it does when reading virtual
columns).
Partitioning/inheritance is supported only on the whole hierarchy at
once (see 8bf6ec3ba3a44448817af47a080587f3b71bee08). Trying to change
the column at only one level will fail, as well as any of the subtrees.
I also added a test to explictily check that we're not accidentally
enqueuing a table scan for verification in phase 3, as avoiding this
kind of work is the whole point of the command.
Logical replication
-------------------
The interaction with logical replication is tricky, since a publication
can have the option to publish generated columns or not (which is the
default).
When not publishing stored generated columns, inserts or updates would
be replicated while backfilling, and would then suddenly stop when the
column is turned into a stored generated column.
One way to avoid this is to set up triggers on the subscriber too,
before altering the column on the publisher. This way updates and
inserts would not lose the column's value on the subscriber, which can
then be migrated by using the new alter table command.
When publishing stored generated columns instead, it is not possible to
have the same column be stored generated on both the publisher and the
subscriber (see Table 29.2 in section 29.6. Generated Column
Replication). The only supported configuration has a regular column on
the side of the subscriber. (Note that this is not specific to this new
command.)
This makes this scenario a lot easier: the column is migrated on the
publisher only, and the subscriber won't lose any value.
To test these two scenarios, I wrote TAP tests for the subscription
suite. However, I'm inclined to not add them to the test suite. I have
attached them to this email separately.
Other changes since v4
----------------------
I have changed phase 2 to be ran at AT_PASS_ADD_OTHERCONSTR, before it
was at AT_PASS_SET_EXPRESSION. The reason to do it there was to reuse
the cleanup steps in ATPostAlterTypeCleanup when a table rewrite did
happen. But since now never rewrite, this is not necessary anymore.
Looking forward to your thoughts on this!
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From fd54518377cf8991574733a1577a63d1f905b23f Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Tue, 30 Jun 2026 15:21:29 +0200
Subject: [PATCH v5] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED ALWAYS STORED USING CONSTRAINT constr_name
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data according to the
intended generation expression, and adding a CHECK constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 43 ++
src/backend/commands/tablecmds.c | 468 +++++++++++++++++
src/backend/parser/gram.y | 30 ++
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 38 +-
src/include/nodes/parsenodes.h | 2 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 490 ++++++++++++++++++
src/test/regress/sql/alter_table.sql | 314 +++++++++++
9 files changed, 1404 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 6dd518752c0..362096bfa9b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED ALWAYS STORED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,48 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED ALWAYS STORED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given constraint. The constraint must be a
+ <literal>CHECK</literal> constraint proving that the values of the
+ column already satisfy the generation expression. The operation will
+ then be performed without rewriting the table.
+ </para>
+
+ <para>
+ The main purpose of this form is to allow adding a stored generated
+ column to a table without performing a table rewrite while holding an
+ <literal>ACCESS EXCLUSIVE</literal> lock.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 472db112fa7..a5a809f49d0 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -790,6 +790,21 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static void checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4776,6 +4791,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddExpressionStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5116,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddExpressionStored: /* ALTER COLUMN ADD GENERATED ALWAYS
+ * STORED USING CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddExpressionStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5494,6 +5518,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddExpressionStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddExpressionStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6702,6 +6732,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddExpressionStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8833,6 +8865,442 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a serial column to a stored generated column"),
+ errdetail("\"%s\" of relation \"%s\" depends on sequence %s",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column referenced in a default expression to a stored generated column"),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* We're not interested in the row */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Subroutine for ATExecAddExpressionStored, used to find a CHECK constraint
+ * to prove that the column values statisfy what will be the generator
+ * expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns both the Oid of the constraint
+ * and the unpacked expression.
+ */
+static Node *
+findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key;
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key,
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 1, &key);
+
+ foundExpr = NULL;
+
+ 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 (strcmp(conname, NameStr(con->conname)) != 0)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ continue;
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ if (IsA(linitial(dist->args), Var))
+ {
+ Var *var = linitial(dist->args);
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(dist->opno, exprType((Node *) var)))
+ {
+ foundExpr = lsecond(dist->args);
+ break;
+ }
+ }
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ if (attisnotnull && 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)))
+ {
+ foundExpr = lsecond(op->args);
+ break;
+ }
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddExprStored).
+ */
+static ObjectAddress
+ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Cannot convert an identity column to a stored generated column"),
+ errdetail("column \"%s\" of relation \"%s\" is an identity column",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column if it's referenced by a partition key"),
+ errdetail("column \"%s\" is part of the partition key of relation \"%s\"",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddExprStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddExprStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname);
+ if (foundConstraintExpr == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column without a constraint to prove that the values are consistent"),
+ attTup->attnotnull ?
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName,
+ colName) :
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName)));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..7bbde3d4e23 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,36 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS STORED USING CONSTRAINT constraint_name */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when STORED USING CONSTRAINT name
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $10;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @10;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddExpressionStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..5d381433004 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("A\t", qr/ALWAYS /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS");
+
+check_completion("S\t", qr/STORED USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT");
+
+check_completion("\t\t", qr/check_gen/,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT offers check constraint names");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 46b9add0604..f318287edd2 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -1898,6 +1907,7 @@ psql_completion(const char *text, int start, int end)
#define prev7_wd (previous_words[6])
#define prev8_wd (previous_words[7])
#define prev9_wd (previous_words[8])
+#define prev10_wd (previous_words[9])
/* Match the last N words before point, case-insensitively. */
#define TailMatches(...) \
@@ -2984,12 +2994,34 @@ match_previous_words(int pattern_id,
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY", "STORED USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev10_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev9_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 4133c404a6b..91c5630b98f 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2524,6 +2524,8 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddExpressionStored, /* add generated always stored using
+ * constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..fb5ab479aab 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddExpressionStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index b891d68d4a7..caee39f773a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,493 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+ Table "testgen.t1"
+ 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 (NOT b IS DISTINCT FROM (a * 2))
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Check constraints:
+ "chk_gen_clause_equal" CHECK (b = (a * 2))
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite
+-------------
+ f
+(1 row)
+
+\d+ testgen.t4
+ Table "testgen.t4"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | 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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+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)
+
+select * from testgen.t5;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\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 (NOT b IS DISTINCT FROM (a * 2))
+
+select * from testgen.t5 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table testgen.t5;
+-- check that the table isn't being scanned during phase 3, even if other
+-- objects depend on the column we are changing. Lowering client_min_messages
+-- makes the message 'verifying table...' be shown here when that happens.
+create table testgen.t6 (a int, b int not null);
+insert into testgen.t6 (a, b) values (1, 2);
+alter table testgen.t6 add constraint c1 check (b > 0);
+alter table testgen.t6 add constraint c2 check (b = a * 2);
+create index on testgen.t6 (b);
+set client_min_messages = 'DEBUG1';
+alter table testgen.t6 alter b
+ add generated always stored using constraint c2;
+-- we expect to *not* see a "verifying table" message here
+reset client_min_messages;
+drop table testgen.t6;
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+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 stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+ERROR: relation "testgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("c" IS NOT DISTINCT FROM (expr))
+rollback;
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+drop table testgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table testgen.intermediate
+drop cascades to table testgen.leaf
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+ERROR: argument of CHECK must be type boolean, not type integer
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default stored using constraint chk_gen...
+ ^
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+ERROR: syntax error at or near ";"
+LINE 1: alter table testgen.t1 alter column b add generated always;
+ ^
+alter table testgen.t1 alter column b add generated always virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...able testgen.t1 alter column b add generated always virtual;
+ ^
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+ERROR: syntax error at or near "using"
+LINE 1: ...le testgen.t1 alter column b add generated always using cons...
+ ^
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...le testgen.t1 alter column b add generated always virtual us...
+ ^
+drop table testgen.t1;
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a serial column to a stored generated column
+DETAIL: "b" of relation "t2" depends on sequence sequence testgen.t2_b_seq
+drop table testgen.t2;
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column referenced in a default expression to a stored generated column
+DETAIL: Column "c" is referenced by generated column "b".
+drop table testgen.t3;
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table testgen.t3;
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+set search_path to :search_path;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: generation expression is not immutable
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+drop schema testgen cascade;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..4a79e3b7219 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,317 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+select pg_relation_filenode('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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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;
+select * from testgen.t5;
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\d+ testgen.t5
+select * from testgen.t5 order by a nulls first;
+drop table testgen.t5;
+
+-- check that the table isn't being scanned during phase 3, even if other
+-- objects depend on the column we are changing. Lowering client_min_messages
+-- makes the message 'verifying table...' be shown here when that happens.
+create table testgen.t6 (a int, b int not null);
+insert into testgen.t6 (a, b) values (1, 2);
+alter table testgen.t6 add constraint c1 check (b > 0);
+alter table testgen.t6 add constraint c2 check (b = a * 2);
+create index on testgen.t6 (b);
+set client_min_messages = 'DEBUG1';
+alter table testgen.t6 alter b
+ add generated always stored using constraint c2;
+-- we expect to *not* see a "verifying table" message here
+reset client_min_messages;
+drop table testgen.t6;
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.root cascade;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+
+alter table testgen.t1 add column b int;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+alter table testgen.t1 alter column b add generated always virtual;
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+drop table testgen.t2;
+
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+set search_path to :search_path;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: c776550e4662385b0ebeac653ae86755008d29f3
--
2.47.0
Attachments:
[text/plain] v5-0001-Support-changing-a-column-into-a-stored-generated.patch (66.8K, ../../DJMFASI8OYBS.2JBMQ7KZ2XOTO@gmail.com/2-v5-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From fd54518377cf8991574733a1577a63d1f905b23f Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Tue, 30 Jun 2026 15:21:29 +0200
Subject: [PATCH v5] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED ALWAYS STORED USING CONSTRAINT constr_name
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data according to the
intended generation expression, and adding a CHECK constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 43 ++
src/backend/commands/tablecmds.c | 468 +++++++++++++++++
src/backend/parser/gram.y | 30 ++
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 38 +-
src/include/nodes/parsenodes.h | 2 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 490 ++++++++++++++++++
src/test/regress/sql/alter_table.sql | 314 +++++++++++
9 files changed, 1404 insertions(+), 3 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 6dd518752c0..362096bfa9b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED ALWAYS STORED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,48 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED ALWAYS STORED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given constraint. The constraint must be a
+ <literal>CHECK</literal> constraint proving that the values of the
+ column already satisfy the generation expression. The operation will
+ then be performed without rewriting the table.
+ </para>
+
+ <para>
+ The main purpose of this form is to allow adding a stored generated
+ column to a table without performing a table rewrite while holding an
+ <literal>ACCESS EXCLUSIVE</literal> lock.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 472db112fa7..a5a809f49d0 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -790,6 +790,21 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static void checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4776,6 +4791,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddExpressionStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5116,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddExpressionStored: /* ALTER COLUMN ADD GENERATED ALWAYS
+ * STORED USING CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddExpressionStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5494,6 +5518,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddExpressionStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddExpressionStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6702,6 +6732,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddExpressionStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8833,6 +8865,442 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a serial column to a stored generated column"),
+ errdetail("\"%s\" of relation \"%s\" depends on sequence %s",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column referenced in a default expression to a stored generated column"),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* We're not interested in the row */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Subroutine for ATExecAddExpressionStored, used to find a CHECK constraint
+ * to prove that the column values statisfy what will be the generator
+ * expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns both the Oid of the constraint
+ * and the unpacked expression.
+ */
+static Node *
+findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key;
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key,
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 1, &key);
+
+ foundExpr = NULL;
+
+ 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 (strcmp(conname, NameStr(con->conname)) != 0)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ continue;
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ if (IsA(linitial(dist->args), Var))
+ {
+ Var *var = linitial(dist->args);
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(dist->opno, exprType((Node *) var)))
+ {
+ foundExpr = lsecond(dist->args);
+ break;
+ }
+ }
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ if (attisnotnull && 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)))
+ {
+ foundExpr = lsecond(op->args);
+ break;
+ }
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddExprStored).
+ */
+static ObjectAddress
+ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Cannot convert an identity column to a stored generated column"),
+ errdetail("column \"%s\" of relation \"%s\" is an identity column",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column if it's referenced by a partition key"),
+ errdetail("column \"%s\" is part of the partition key of relation \"%s\"",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddExprStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddExprStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname);
+ if (foundConstraintExpr == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column without a constraint to prove that the values are consistent"),
+ attTup->attnotnull ?
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName,
+ colName) :
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName)));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..7bbde3d4e23 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,36 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS STORED USING CONSTRAINT constraint_name */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when STORED USING CONSTRAINT name
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $10;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @10;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddExpressionStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..5d381433004 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("A\t", qr/ALWAYS /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS");
+
+check_completion("S\t", qr/STORED USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT");
+
+check_completion("\t\t", qr/check_gen/,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT offers check constraint names");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 46b9add0604..f318287edd2 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -1898,6 +1907,7 @@ psql_completion(const char *text, int start, int end)
#define prev7_wd (previous_words[6])
#define prev8_wd (previous_words[7])
#define prev9_wd (previous_words[8])
+#define prev10_wd (previous_words[9])
/* Match the last N words before point, case-insensitively. */
#define TailMatches(...) \
@@ -2984,12 +2994,34 @@ match_previous_words(int pattern_id,
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY", "STORED USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev10_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev9_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 4133c404a6b..91c5630b98f 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2524,6 +2524,8 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddExpressionStored, /* add generated always stored using
+ * constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..fb5ab479aab 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddExpressionStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index b891d68d4a7..caee39f773a 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,493 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+ Table "testgen.t1"
+ 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 (NOT b IS DISTINCT FROM (a * 2))
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Check constraints:
+ "chk_gen_clause_equal" CHECK (b = (a * 2))
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite
+-------------
+ f
+(1 row)
+
+\d+ testgen.t4
+ Table "testgen.t4"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | 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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+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)
+
+select * from testgen.t5;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\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 (NOT b IS DISTINCT FROM (a * 2))
+
+select * from testgen.t5 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table testgen.t5;
+-- check that the table isn't being scanned during phase 3, even if other
+-- objects depend on the column we are changing. Lowering client_min_messages
+-- makes the message 'verifying table...' be shown here when that happens.
+create table testgen.t6 (a int, b int not null);
+insert into testgen.t6 (a, b) values (1, 2);
+alter table testgen.t6 add constraint c1 check (b > 0);
+alter table testgen.t6 add constraint c2 check (b = a * 2);
+create index on testgen.t6 (b);
+set client_min_messages = 'DEBUG1';
+alter table testgen.t6 alter b
+ add generated always stored using constraint c2;
+-- we expect to *not* see a "verifying table" message here
+reset client_min_messages;
+drop table testgen.t6;
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+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 stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+ERROR: relation "testgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("c" IS NOT DISTINCT FROM (expr))
+rollback;
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+drop table testgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table testgen.intermediate
+drop cascades to table testgen.leaf
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+ERROR: argument of CHECK must be type boolean, not type integer
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default stored using constraint chk_gen...
+ ^
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+ERROR: syntax error at or near ";"
+LINE 1: alter table testgen.t1 alter column b add generated always;
+ ^
+alter table testgen.t1 alter column b add generated always virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...able testgen.t1 alter column b add generated always virtual;
+ ^
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+ERROR: syntax error at or near "using"
+LINE 1: ...le testgen.t1 alter column b add generated always using cons...
+ ^
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...le testgen.t1 alter column b add generated always virtual us...
+ ^
+drop table testgen.t1;
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a serial column to a stored generated column
+DETAIL: "b" of relation "t2" depends on sequence sequence testgen.t2_b_seq
+drop table testgen.t2;
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column referenced in a default expression to a stored generated column
+DETAIL: Column "c" is referenced by generated column "b".
+drop table testgen.t3;
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table testgen.t3;
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+set search_path to :search_path;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: generation expression is not immutable
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+drop schema testgen cascade;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..4a79e3b7219 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,317 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+select pg_relation_filenode('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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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;
+select * from testgen.t5;
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\d+ testgen.t5
+select * from testgen.t5 order by a nulls first;
+drop table testgen.t5;
+
+-- check that the table isn't being scanned during phase 3, even if other
+-- objects depend on the column we are changing. Lowering client_min_messages
+-- makes the message 'verifying table...' be shown here when that happens.
+create table testgen.t6 (a int, b int not null);
+insert into testgen.t6 (a, b) values (1, 2);
+alter table testgen.t6 add constraint c1 check (b > 0);
+alter table testgen.t6 add constraint c2 check (b = a * 2);
+create index on testgen.t6 (b);
+set client_min_messages = 'DEBUG1';
+alter table testgen.t6 alter b
+ add generated always stored using constraint c2;
+-- we expect to *not* see a "verifying table" message here
+reset client_min_messages;
+drop table testgen.t6;
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.root cascade;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+
+alter table testgen.t1 add column b int;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+alter table testgen.t1 alter column b add generated always virtual;
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+drop table testgen.t2;
+
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+set search_path to :search_path;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: c776550e4662385b0ebeac653ae86755008d29f3
--
2.47.0
[application/x-perl] 039_stored_generated_published.pl (3.5K, ../../DJMFASI8OYBS.2JBMQ7KZ2XOTO@gmail.com/3-039_stored_generated_published.pl)
download
[application/x-perl] 040_stored_generated_not_published.pl (4.5K, ../../DJMFASI8OYBS.2JBMQ7KZ2XOTO@gmail.com/4-040_stored_generated_not_published.pl)
download
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-07-03 06:42 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-07-03 06:42 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; Laurenz Albe <laurenz.albe@cybertec.at>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
v5 had a rather brittle test that was relying on a DEBUG message being
emitted when phase 3 verifies or rewrites a table (via
client_min_messages). That was failing on the CI because the output
depends on any other setting that might affect logging (in this case it
was log_statement).
The attached v6 replaces it with an injection_point test. If adding new
injection points to detect scans/rewrites is considered too much, I can
back it out and set/reset log_statement too. But I do prefer the
injection_point test.
If the new injection point is good, there are also a couple more tests
which might be updated to use it.
Regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From f016839c71f321cae27f1f7b6e543c309ce2cc09 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 3 Jul 2026 07:52:56 +0200
Subject: [PATCH v6] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED ALWAYS STORED USING CONSTRAINT constr_name
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data according to the
intended generation expression, and adding a CHECK constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 43 ++
src/backend/commands/tablecmds.c | 479 ++++++++++++++++++
src/backend/parser/gram.y | 30 ++
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 38 +-
src/include/nodes/parsenodes.h | 2 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 476 +++++++++++++++++
src/test/regress/sql/alter_table.sql | 299 +++++++++++
13 files changed, 1448 insertions(+), 4 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b95a43e1699 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED ALWAYS STORED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,48 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED ALWAYS STORED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given constraint. The constraint must be a
+ <literal>CHECK</literal> constraint proving that the values of the
+ column already satisfy the generation expression. The operation will
+ then be performed without rewriting the table.
+ </para>
+
+ <para>
+ The main purpose of this form is to allow adding a stored generated
+ column to a table without performing a table rewrite while holding an
+ <literal>ACCESS EXCLUSIVE</literal> lock.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 95abaf4890c..1b04660c8c6 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -790,6 +791,21 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static void checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4803,6 +4819,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddExpressionStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5127,6 +5144,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddExpressionStored: /* ALTER COLUMN ADD GENERATED ALWAYS
+ * STORED USING CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddExpressionStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5521,6 +5546,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddExpressionStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddExpressionStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6403,13 +6434,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6729,6 +6770,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddExpressionStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8876,6 +8919,442 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a serial column to a stored generated column"),
+ errdetail("\"%s\" of relation \"%s\" depends on sequence %s",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column referenced in a default expression to a stored generated column"),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* We're not interested in the row */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Subroutine for ATExecAddExpressionStored, used to find a CHECK constraint
+ * to prove that the column values statisfy what will be the generator
+ * expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns both the Oid of the constraint
+ * and the unpacked expression.
+ */
+static Node *
+findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key;
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key,
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 1, &key);
+
+ foundExpr = NULL;
+
+ 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 (strcmp(conname, NameStr(con->conname)) != 0)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ continue;
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ if (IsA(linitial(dist->args), Var))
+ {
+ Var *var = linitial(dist->args);
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(dist->opno, exprType((Node *) var)))
+ {
+ foundExpr = lsecond(dist->args);
+ break;
+ }
+ }
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ if (attisnotnull && 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)))
+ {
+ foundExpr = lsecond(op->args);
+ break;
+ }
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddExprStored).
+ */
+static ObjectAddress
+ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Cannot convert an identity column to a stored generated column"),
+ errdetail("column \"%s\" of relation \"%s\" is an identity column",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column if it's referenced by a partition key"),
+ errdetail("column \"%s\" is part of the partition key of relation \"%s\"",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddExprStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddExprStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname);
+ if (foundConstraintExpr == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column without a constraint to prove that the values are consistent"),
+ attTup->attnotnull ?
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName,
+ colName) :
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName)));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..7bbde3d4e23 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,36 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS STORED USING CONSTRAINT constraint_name */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when STORED USING CONSTRAINT name
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $10;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @10;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddExpressionStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..5d381433004 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("A\t", qr/ALWAYS /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS");
+
+check_completion("S\t", qr/STORED USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT");
+
+check_completion("\t\t", qr/check_gen/,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT offers check constraint names");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index e4bc2c93145..4c6f05f8056 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -1884,6 +1893,7 @@ psql_completion(const char *text, int start, int end)
#define prev7_wd (previous_words[6])
#define prev8_wd (previous_words[7])
#define prev9_wd (previous_words[8])
+#define prev10_wd (previous_words[9])
/* Match the last N words before point, case-insensitively. */
#define TailMatches(...) \
@@ -2970,12 +2980,34 @@ match_previous_words(int pattern_id,
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY", "STORED USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev10_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev9_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index e03556399ab..7906bb4f9eb 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2525,6 +2525,8 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddExpressionStored, /* add generated always stored using
+ * constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index c01d2fb095c..ad214a8543e 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..27dcc0e17a7
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED ALWAYS STORED USING CONSTRAINT c2;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb023..bd431c99cd1 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..b9599bec175
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED ALWAYS STORED USING CONSTRAINT c2;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..fb5ab479aab 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddExpressionStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..075a2dd7bb6 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,479 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+ Table "testgen.t1"
+ 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 (NOT b IS DISTINCT FROM (a * 2))
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Check constraints:
+ "chk_gen_clause_equal" CHECK (b = (a * 2))
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite
+-------------
+ f
+(1 row)
+
+\d+ testgen.t4
+ Table "testgen.t4"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | 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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+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)
+
+select * from testgen.t5;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\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 (NOT b IS DISTINCT FROM (a * 2))
+
+select * from testgen.t5 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table testgen.t5;
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+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 stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+ERROR: relation "testgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("c" IS NOT DISTINCT FROM (expr))
+rollback;
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+drop table testgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table testgen.intermediate
+drop cascades to table testgen.leaf
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+ERROR: argument of CHECK must be type boolean, not type integer
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default stored using constraint chk_gen...
+ ^
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+ERROR: syntax error at or near ";"
+LINE 1: alter table testgen.t1 alter column b add generated always;
+ ^
+alter table testgen.t1 alter column b add generated always virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...able testgen.t1 alter column b add generated always virtual;
+ ^
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+ERROR: syntax error at or near "using"
+LINE 1: ...le testgen.t1 alter column b add generated always using cons...
+ ^
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...le testgen.t1 alter column b add generated always virtual us...
+ ^
+drop table testgen.t1;
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a serial column to a stored generated column
+DETAIL: "b" of relation "t2" depends on sequence sequence testgen.t2_b_seq
+drop table testgen.t2;
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column referenced in a default expression to a stored generated column
+DETAIL: Column "c" is referenced by generated column "b".
+drop table testgen.t3;
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table testgen.t3;
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+set search_path to :search_path;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: generation expression is not immutable
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+drop schema testgen cascade;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..04e4c6e619d 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,302 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+select pg_relation_filenode('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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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;
+select * from testgen.t5;
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\d+ testgen.t5
+select * from testgen.t5 order by a nulls first;
+drop table testgen.t5;
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.root cascade;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+
+alter table testgen.t1 add column b int;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+alter table testgen.t1 alter column b add generated always virtual;
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+drop table testgen.t2;
+
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+set search_path to :search_path;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 6d4ca6de97770cdaee18517dd2f8fe8f4ecee187
--
2.47.0
Attachments:
[text/plain] v6-0001-Support-changing-a-column-into-a-stored-generated.patch (70.3K, ../../DJOPT622HYJK.3JRH1VQCV5CDL@gmail.com/2-v6-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From f016839c71f321cae27f1f7b6e543c309ce2cc09 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 3 Jul 2026 07:52:56 +0200
Subject: [PATCH v6] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED ALWAYS STORED USING CONSTRAINT constr_name
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data according to the
intended generation expression, and adding a CHECK constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 43 ++
src/backend/commands/tablecmds.c | 479 ++++++++++++++++++
src/backend/parser/gram.y | 30 ++
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 38 +-
src/include/nodes/parsenodes.h | 2 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 476 +++++++++++++++++
src/test/regress/sql/alter_table.sql | 299 +++++++++++
13 files changed, 1448 insertions(+), 4 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b95a43e1699 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED ALWAYS STORED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,48 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED ALWAYS STORED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given constraint. The constraint must be a
+ <literal>CHECK</literal> constraint proving that the values of the
+ column already satisfy the generation expression. The operation will
+ then be performed without rewriting the table.
+ </para>
+
+ <para>
+ The main purpose of this form is to allow adding a stored generated
+ column to a table without performing a table rewrite while holding an
+ <literal>ACCESS EXCLUSIVE</literal> lock.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 95abaf4890c..1b04660c8c6 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -790,6 +791,21 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode);
+static void checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4803,6 +4819,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddExpressionStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5127,6 +5144,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddExpressionStored: /* ALTER COLUMN ADD GENERATED ALWAYS
+ * STORED USING CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddExpressionStored(rel, cmd, recurse, recursing, lockmode);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5521,6 +5546,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddExpressionStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddExpressionStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6403,13 +6434,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6729,6 +6770,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddExpressionStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8876,6 +8919,442 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddExpressionStored(Relation rel,
+ AlterTableCmd *cmd,
+ bool recurse, bool recursing,
+ LOCKMODE lockmode)
+{
+ /*
+ * Reject ONLY if there are child tables.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), lockmode))
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too")));
+
+ /*
+ * Cannot change only inherited columns to be stored generated columns.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
+ errmsg("cannot change inherited column to be a stored generated column")));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddExprStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a serial column to a stored generated column"),
+ errdetail("\"%s\" of relation \"%s\" depends on sequence %s",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column referenced in a default expression to a stored generated column"),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* We're not interested in the row */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Subroutine for ATExecAddExpressionStored, used to find a CHECK constraint
+ * to prove that the column values statisfy what will be the generator
+ * expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns both the Oid of the constraint
+ * and the unpacked expression.
+ */
+static Node *
+findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key;
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key,
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 1, &key);
+
+ foundExpr = NULL;
+
+ 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 (strcmp(conname, NameStr(con->conname)) != 0)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ continue;
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ if (IsA(linitial(dist->args), Var))
+ {
+ Var *var = linitial(dist->args);
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(dist->opno, exprType((Node *) var)))
+ {
+ foundExpr = lsecond(dist->args);
+ break;
+ }
+ }
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ if (attisnotnull && 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)))
+ {
+ foundExpr = lsecond(op->args);
+ break;
+ }
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddExprStored).
+ */
+static ObjectAddress
+ATExecAddExpressionStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("Cannot convert an identity column to a stored generated column"),
+ errdetail("column \"%s\" of relation \"%s\" is an identity column",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column if it's referenced by a partition key"),
+ errdetail("column \"%s\" is part of the partition key of relation \"%s\"",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddExprStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddExprStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname);
+ if (foundConstraintExpr == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert a column into a stored generated column without a constraint to prove that the values are consistent"),
+ attTup->attnotnull ?
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName,
+ colName) :
+ errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
+ def->conname,
+ colName)));
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..7bbde3d4e23 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2725,6 +2725,36 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS STORED USING CONSTRAINT constraint_name */
+ | ALTER opt_column ColId ADD_P GENERATED generated_when STORED USING CONSTRAINT name
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $10;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = $6;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @10;
+
+ /*
+ * Like in the case of ColConstraintElem, we cannot handle
+ * this in the grammar because IDENTITY allows both ALWAYS
+ * and BY DEFAULT, while generated columns only allow
+ * ALWAYS. This would lead to shift/reduce conflicts.
+ */
+ if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+ ereport(ERROR,
+ (errcode(ERRCODE_SYNTAX_ERROR),
+ errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+ parser_errposition(@6)));
+
+ n->subtype = AT_AddExpressionStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..5d381433004 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("A\t", qr/ALWAYS /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS");
+
+check_completion("S\t", qr/STORED USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT");
+
+check_completion("\t\t", qr/check_gen/,
+ "complete ALTER COLUMN <col> ADD GENERATED ALWAYS STORED USING CONSTRAINT offers check constraint names");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index e4bc2c93145..4c6f05f8056 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -1884,6 +1893,7 @@ psql_completion(const char *text, int start, int end)
#define prev7_wd (previous_words[6])
#define prev8_wd (previous_words[7])
#define prev9_wd (previous_words[8])
+#define prev10_wd (previous_words[9])
/* Match the last N words before point, case-insensitively. */
#define TailMatches(...) \
@@ -2970,12 +2980,34 @@ match_previous_words(int pattern_id,
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY", "STORED USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev10_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS STORED USING
+ * CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS", "STORED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev9_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index e03556399ab..7906bb4f9eb 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2525,6 +2525,8 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddExpressionStored, /* add generated always stored using
+ * constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index c01d2fb095c..ad214a8543e 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..27dcc0e17a7
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED ALWAYS STORED USING CONSTRAINT c2;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb023..bd431c99cd1 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..b9599bec175
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED ALWAYS STORED USING CONSTRAINT c2;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..fb5ab479aab 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddExpressionStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..075a2dd7bb6 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,479 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+ Table "testgen.t1"
+ 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 (NOT b IS DISTINCT FROM (a * 2))
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+drop table testgen.t1;
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+ Table "testgen.t1"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | generated always as (a * 2) stored | plain | |
+Check constraints:
+ "chk_gen_clause_equal" CHECK (b = (a * 2))
+Not-null constraints:
+ "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+ a | b | expected | correct
+----+----+----------+---------
+ 1 | 2 | 2 | t
+ 2 | 4 | 4 | t
+ 3 | 6 | 6 | t
+ 4 | 8 | 8 | t
+ 5 | 10 | 10 | t
+ 6 | 12 | 12 | t
+ 7 | 14 | 14 | t
+ 8 | 16 | 16 | t
+ 9 | 18 | 18 | t
+ 10 | 20 | 20 | t
+(10 rows)
+
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause_does_not_exist" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" IS NOT DISTINCT FROM (expr))
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
+select pg_relation_filenode('testgen.t4') as t4_filenode_after \gset
+select :t4_filenode_before != :t4_filenode_after as did_rewrite;
+ did_rewrite
+-------------
+ f
+(1 row)
+
+\d+ testgen.t4
+ Table "testgen.t4"
+ Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a | integer | | | | plain | |
+ b | integer | | not null | | 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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+select locktype, mode from pg_locks
+where relation = 'testgen.t5'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+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)
+
+select * from testgen.t5;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\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 (NOT b IS DISTINCT FROM (a * 2))
+
+select * from testgen.t5 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table testgen.t5;
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+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 stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+drop table testgen.tpart;
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+ERROR: relation "testgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
+DETAIL: could not find a valid constraint "chk_gen_clause" CHECK ("c" IS NOT DISTINCT FROM (expr))
+rollback;
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: ALTER COLUMN / ADD GENERATED ALWAYS STORED must be applied to child tables too
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+ERROR: cannot change inherited column to be a stored generated column
+rollback;
+drop table testgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table testgen.intermediate
+drop cascades to table testgen.leaf
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+ERROR: relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+ERROR: argument of CHECK must be type boolean, not type integer
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+ERROR: column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+ERROR: for a generated column, GENERATED ALWAYS must be specified
+LINE 2: add generated by default stored using constraint chk_gen...
+ ^
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+ERROR: syntax error at or near ";"
+LINE 1: alter table testgen.t1 alter column b add generated always;
+ ^
+alter table testgen.t1 alter column b add generated always virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...able testgen.t1 alter column b add generated always virtual;
+ ^
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+ERROR: syntax error at or near "using"
+LINE 1: ...le testgen.t1 alter column b add generated always using cons...
+ ^
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...le testgen.t1 alter column b add generated always virtual us...
+ ^
+drop table testgen.t1;
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: column "b" of relation "t2" is already a generated column
+drop table testgen.t2;
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+ERROR: Cannot convert an identity column to a stored generated column
+DETAIL: column "b" of relation "t2" is an identity column
+drop table testgen.t2;
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a serial column to a stored generated column
+DETAIL: "b" of relation "t2" depends on sequence sequence testgen.t2_b_seq
+drop table testgen.t2;
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot convert a column referenced in a default expression to a stored generated column
+DETAIL: Column "c" is referenced by generated column "b".
+drop table testgen.t3;
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table testgen.t3;
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+ERROR: cannot convert a column into a stored generated column if it's referenced by a partition key
+DETAIL: column "c" is part of the partition key of relation "t3"
+drop table testgen.t3;
+set search_path to :search_path;
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+ERROR: generation expression is not immutable
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+drop schema testgen cascade;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..04e4c6e619d 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,302 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS STORED USING CONSTRAINT name
+-- turning a regular column into a stored generated column without a rewrite
+create schema testgen;
+
+create table testgen.t1 (a int, b int);
+insert into testgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+ from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- accepts = instead of IS NOT DISTINCT FROM when the destination
+-- column is NOT NULL
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+select x, x * 2 from generate_series(1, 10) x;
+alter table testgen.t1 add constraint chk_gen_clause_equal check (b = a * 2);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_equal;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+from testgen.t1 order by a;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint does not exist. When the destination
+-- column is NOT NULL, the error message mentions both constraint
+-- shapes which would be valid
+create table testgen.t1 (a int, b int not null);
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause_does_not_exist;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not valid
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not valid;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when the constraint is not enforced
+create table testgen.t1 (a int, b int);
+alter table testgen.t1 add constraint chk_gen_clause check (b is not distinct from a * 2) not enforced;
+alter table testgen.t1 alter column b
+ add generated always stored using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- without rewriting the table doesn't touch the index either
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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
+-- fails when the constraint exists but doesn't have the expected shape
+create table testgen.t4 (a int, b int not null);
+insert into testgen.t4 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t4 add constraint chk_gen_clause check (b >= a * 2);
+select pg_relation_filenode('testgen.t4') as t4_filenode_before \gset
+alter table testgen.t4 alter column b add generated always stored using constraint chk_gen_clause;
+select pg_relation_filenode('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;
+-- test nulls, too
+insert into testgen.t5 (a) values (null);
+alter table testgen.t5 add column b int;
+-- take care of new and updated columns
+create function testgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger testgen_gen
+ before insert or update on testgen.t5
+ for each row execute function testgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table testgen.t5
+ add constraint chk_gen_clause check (b is not distinct from 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 doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table testgen.t5 alter column b
+ add generated always stored using constraint chk_gen_clause;
+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;
+select * from testgen.t5;
+-- verify that it's still possible to insert rows (the trigger is still
+-- running at this point)
+insert into testgen.t5 (a) values (400);
+drop trigger testgen_gen on testgen.t5;
+drop function testgen.gen();
+insert into testgen.t5 (a) values (500);
+\d+ testgen.t5
+select * from testgen.t5 order by a nulls first;
+drop table testgen.t5;
+
+-- test support for partitioned tables and inheritance
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a * 2);
+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 * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+-- 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 stored using constraint chk_gen_clause;
+rollback;
+
+-- altering only the parent table is not allowed
+begin;
+-- expected: error
+alter table only testgen.tpart alter column b
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table testgen.root (a int, b int, c int);
+create table testgen.intermediate () inherits (testgen.root);
+create table testgen.leaf () inherits (testgen.intermediate);
+alter table testgen.tpart
+ add constraint chk_gen_clause check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+-- ... hence all these should result in an error
+begin;
+alter table only testgen.root alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.intermediate alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+begin;
+alter table only testgen.leaf alter column c
+ add generated always stored using constraint chk_gen_clause;
+rollback;
+
+drop table testgen.root cascade;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+ add generated always stored using constraint cdoesnotexist;
+
+create table testgen.t1 (a int);
+alter table testgen.t1 add constraint chk_gen_clause check (1);
+
+alter table testgen.t1 alter column doesnotexist
+ add generated always stored using constraint chk_gen_clause;
+
+alter table testgen.t1 add column b int;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+ add generated by default stored using constraint chk_gen_clause;
+
+-- invalid: only supports STORED. These are all syntax errors.
+alter table testgen.t1 alter column b add generated always;
+alter table testgen.t1 alter column b add generated always virtual;
+alter table testgen.t1 alter column b add generated always using constraint chk_gen_clause;
+alter table testgen.t1 alter column b add generated always virtual using constraint chk_gen_clause;
+drop table testgen.t1;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is an identity column
+create table testgen.t2 (a int, b int generated always as identity);
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+create table testgen.t2 (a int, b int generated by default as identity );
+alter table testgen.t2 alter column b add generated always stored using constraint doesnotexist;
+drop table testgen.t2;
+
+-- invalid: b is a serial column
+create table testgen.t2 (a int, b bigserial);
+alter table testgen.t2 add constraint chk_gen_clause check (b is not distinct from (1));
+alter table testgen.t2 alter column b add generated always stored using constraint chk_gen_clause;
+drop table testgen.t2;
+
+-- invalid: c is referenced by another column's default expr
+create table testgen.t3 (a int, b int generated always as (c + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c references another generated column
+create table testgen.t3 (a int, b int generated always as (a + 1), c int);
+alter table testgen.t3 add constraint chk_gen_clause check (c is not distinct from (b + 1));
+alter table testgen.t3 alter column c add generated always stored using constraint chk_gen_clause;
+drop table testgen.t3;
+
+-- invalid: c is referenced in a partition key
+create table testgen.t3 (a int, b int, c int) partition by hash (c);
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table testgen.t3 (a int, b int, c int) partition by hash ((c));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to testgen, public;
+create table t3 (a int, b int, c int) partition by range ((t3));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+create table t3 (a int, b int, c int) partition by range ((t3 is null));
+alter table testgen.t3 alter column c add generated always stored using constraint doesnotexist;
+drop table testgen.t3;
+set search_path to :search_path;
+
+create table testgen.t3 (a int, b int);
+-- invalid: expr must be immutable
+alter table testgen.t3 add constraint chk_gen_clause check (b is not distinct from (a + random()::int));
+alter table testgen.t3 alter column b
+ add generated always stored using constraint chk_gen_clause;
+alter table testgen.t3 drop constraint chk_gen_clause;
+drop table testgen.t3;
+
+drop schema testgen cascade;
base-commit: 6d4ca6de97770cdaee18517dd2f8fe8f4ecee187
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-07-07 18:16 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-07-07 18:16 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Fri, 2026-07-03 at 08:42 +0200, Alberto Piai wrote:
> In the attached v5 patch I've implemented this design, and went one step
> further (let me know what you think). While discussing this with my
> colleagues at work, the question came up (thanks, Philip!): now that we
> mention the constraint explicitly, what's the point of repeating the
> expression too? The constraint already defines an equality to an
> expression. I think this is a very good point, and it removes one
> further way in which the operation could fail, so I went ahead and
> changed the command to not mention the expression. It takes the
> expression defined in the constraint and uses _that_ as the generator
> expression of the column.
I agree with that idea, it shortend the syntax and leaves less room for
mistakes.
The syntax you ended up with (ADD GENERATED ALWAYS STORED USING CONSTRAINT)
is ugly as hell. I see your point in having ALWAYS and STORED, but perhaps
ADD GENERATED ALWAYS USING CONSTRAINT ... STORED would be better, as it is
syntactically more like GENERATED ALWAYS AS (...) STORED, which would make
it easier to remember.
Or perhaps ADD GENERATED USING CONSTRAINT would be enough, since ALWAYS and
STORED are the only possible choice anyway. I am a bit uncertain on what
is best here.
> > I feel that automatically dropping the constraint is a bit too much
> > black magic, but it is more a feeling than a conviction.
>
> I don't have a strong opinion on whether to cleanup or not, I'll gladly
> take your input. This version of the patch does not drop the constraint
> anymore.
I like it that way, because ALTER TABLE ATTACH PARTITION and ALTER TABLE
ALTER COLUMN SET NOT NULL don't drop the constraint they use either.
But the case is not exactly the same, so I won't insist.
> This version addresses your inputs from the last review:
>
> - I added documentation for the new alter table form to alter_table.sgml
> - Tab completion for psql is there now
> - The missing error conditions in case of an identity column or
> sequences are now handled, more about this in the next section.
Thanks.
I think you didn't adapt the documentation sufficiently after you dropped
the generation expression from the syntax:
> + This form changes a regular column into a stored generated column, using
> + the expression from the given constraint. The constraint must be a
> + <literal>CHECK</literal> constraint proving that the values of the
> + column already satisfy the generation expression. The operation will
> + then be performed without rewriting the table.
The "generation expression" suddenly surfaces towards the end of the paragraph
and makes the reader wonder where it comes from.
Perhaps:
This form changes a regular column into a stored generated column, using
the expression from the given check constraint as generation expression.
The operation will be performed without rewriting the table, which avoids
holding an <literal>ACCESS EXCLUSIVE</literal> lock for a longer time.
That would render the immediately following paragraph unnecessary.
The patch applies, builds and passes the regression tests.
There is a weird asymmetry in that the order in which you write the check
constraint matters:
CREATE TABLE tab (a integer PRIMARY KEY, b integer NOT NULL);
INSERT INTO tab VALUES (1, 2);
-- this fails
ALTER TABLE tab ADD CONSTRAINT c CHECK (2 * a = b);
ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
DETAIL: could not find a valid constraint "c" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
-- but this works
ALTER TABLE tab DROP CONSTRAINT c;
ALTER TABLE tab ADD CONSTRAINT c CHECK (b = 2 * a);
ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
I think that both variants should be accepted, but I am not certain.
The following error message is not very helpful:
CREATE TABLE tab (
a integer DEFAULT 2,
b integer
CONSTRAINT con CHECK (b IS NOT DISTINCT FROM 2 + random())
);
ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT con;
ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
DETAIL: could not find a valid constraint "con" CHECK ("b" IS NOT DISTINCT FROM (expr))
Perhaps it would be better to proceed in three steps:
- find a check constraint with the given name
- verify that the check constraint has the correct shape
- verify that the expression is immutable
Each step could have a different, helpful error message.
I looked at the code too, and I could only spot smaller problems:
> --- a/src/backend/commands/tablecmds.c
> +++ b/src/backend/commands/tablecmds.c
> [...]
> +ATPrepAddExpressionStored(Relation rel,
> + AlterTableCmd *cmd,
> + bool recurse, bool recursing,
> + LOCKMODE lockmode)
> [...]
> + /*
> + * Cannot change only inherited columns to be stored generated columns.
> + */
> + if (!recursing)
> + {
> [...]
> + if (attTup->attinhcount > 0)
> + ereport(ERROR,
> + (errcode(ERRCODE_INVALID_TABLE_DEFINITION),
> + errmsg("cannot change inherited column to be a stored generated column")));
> + }
The comment is cryptic, and I had to read the code to understand what you mean.
Perhaps:
/* convert inherited columns only if the entire hierarchy is changed */
> +/*
> + * Detect dependencies which should stop us from turning a regular column
> + * into a stored generated column.
> + */
> +static void
> +checkDependenciesForAddExprStored(Relation rel,
> + AttrNumber attnum,
> + const char *colName)
> [...]
> + switch (foundObject.classId)
> + {
> + case RelationRelationId:
> + {
> + char relKind = get_rel_relkind(foundObject.objectId);
> +
> + if (relKind == RELKIND_SEQUENCE)
> + ereport(ERROR,
> + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("cannot convert a serial column to a stored generated column"),
> + errdetail("\"%s\" of relation \"%s\" depends on sequence %s",
> + colName, RelationGetRelationName(rel),
> + getObjectDescription(&foundObject, false))));
There is an extra space in the error message.
> + break;
> + }
> + case AttrDefaultRelationId:
> + {
> + ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
> +
> + if (col.objectId == RelationGetRelid(rel) &&
> + col.objectSubId == attnum)
> + {
> + /*
> + * Ignore the column's own default expression. We
> + * handle sequences above, and for a column which is
> + * already a generated column we should never get
> + * here.
> + */
It's not strictly required, but it would be great if you could run pgindent
to get comments and other parts of the code formatted properly.
> + }
> + else
> + {
> + ereport(ERROR,
> + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("cannot convert a column referenced in a default expression to a stored generated column"),
> + errdetail("Column \"%s\" is referenced by generated column \"%s\".",
> + colName,
> + get_attname(col.objectId, col.objectSubId, false))));
This is confusing me.
The error message seems to suggest that you cannot use a generated column in a
DEFAULT expression. But you can never use other columns in a default expression
anyway, right?
The detail message says something different, namely that the column is referenced
my a generated column (do you mean it is used in the generation expression)?
I believe that if I get confused by the error message, the average user will
also get confused.
> + }
> + break;
> + }
> + default:
> + /* We're not interested in the row */
> + break;
> + }
Perhaps a better comment would be
/* other dependencies, e.g. by views, are no problem */
> +/*
> + * Subroutine for ATExecAddExpressionStored, used to find a CHECK constraint
> + * to prove that the column values statisfy what will be the generator
> + * expression.
> + *
> [...]
> + *
> + * If a valid constraint is found, this returns both the Oid of the constraint
> + * and the unpacked expression.
> + */
> +static Node *
> +findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
> + bool attisnotnull,
> + const char *conname)
I see that it returns a Node, not an Oid and an expression.
If the Oid of the constraint is actually returned somewhere inside the
"Node", the comment should be more specific about it.
> +static ObjectAddress
> +ATExecAddExpressionStored(AlteredTableInfo *tab,
> + Relation rel,
> + const char *colName,
> + Constraint *def)
> +{
> [...]
> + if (attTup->attidentity)
> + ereport(ERROR,
> + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("Cannot convert an identity column to a stored generated column"),
> + errdetail("column \"%s\" of relation \"%s\" is an identity column",
> + colName, RelationGetRelationName(rel))));
Message style: the main error message should start with a lower case character,
and the detail message should be a whole sentence (initial capitalization, period).
Error messages should try not to exceed 80 characters (no problem with that here),
> + if (has_partition_attrs(rel, colRefs, &is_expr))
> + ereport(ERROR,
> + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("cannot convert a column into a stored generated column if it's referenced by a partition key"),
> + errdetail("column \"%s\" is part of the partition key of relation \"%s\"",
> + colName, RelationGetRelationName(rel))));
Same as above, plus the error message is too long.
Perhaps:
ERROR: cannot convert a column used in a partitioning key to a generated column
I don't think we need to say "stored" everywhere.
> + if (foundConstraintExpr == NULL)
> + ereport(ERROR,
> + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
> + errmsg("cannot convert a column into a stored generated column without a constraint to prove that the values are consistent"),
> + attTup->attnotnull ?
> + errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
> + def->conname,
> + colName,
> + colName) :
> + errdetail("could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM (expr))",
> + def->conname,
> + colName)));
Again, the message must be shorter, and perhaps a hint would be better than a detail:
ERROR: cannot find a check constraint to prove that the column values are correct
HINT: The constraint must be CHECK ("%s" = expr) or CHECK ("%s" IS NOT DISTINCT FROM expr).
I talked about this error message in the beginning. It is thrown whenever
findUsableConstraintForAddExprStored() returns nothing, which is a bit too unspecific.
Perhaps you could have findUsableConstraintForAddExprStored() throw the errors
instead, then they could be more specific and pertinent.
I don't usually mention that, but since you are a new contributor and explicitly
asked several people for a review (which is fine!): it is expected that you also
review other's patches in the commitfest.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-07-10 00:10 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-07-10 00:10 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
Hi Laurenz,
thanks a lot for another thoughtful review, I appreciate it a lot. Let
me address the most important point first.
On Tue Jul 7, 2026 at 8:16 PM CEST, Laurenz Albe wrote:
> I don't usually mention that, but since you are a new contributor and explicitly
> asked several people for a review (which is fine!): it is expected that you also
> review other's patches in the commitfest.
You are right to bring that up, it is clearly spelled out in the
developer docs on the wiki and I agree that it's the best (the only?)
way to keep a project going with this volume of contributions.
Until now, I have dedicated all my bandwidth to learning as much as I
could to get this patch to a presentable state. From now on, I'll do my
best to get involved with patch reviews!
>> In the attached v5 patch I've implemented this design, and went one step
>> further (let me know what you think). While discussing this with my
>> colleagues at work, the question came up (thanks, Philip!): now that we
>> mention the constraint explicitly, what's the point of repeating the
>> expression too? The constraint already defines an equality to an
>> expression. I think this is a very good point, and it removes one
>> further way in which the operation could fail, so I went ahead and
>> changed the command to not mention the expression. It takes the
>> expression defined in the constraint and uses _that_ as the generator
>> expression of the column.
>
> I agree with that idea, it shortend the syntax and leaves less room for
> mistakes.
>
> The syntax you ended up with (ADD GENERATED ALWAYS STORED USING CONSTRAINT)
> is ugly as hell. I see your point in having ALWAYS and STORED, but perhaps
> ADD GENERATED ALWAYS USING CONSTRAINT ... STORED would be better, as it is
> syntactically more like GENERATED ALWAYS AS (...) STORED, which would make
> it easier to remember.
>
> Or perhaps ADD GENERATED USING CONSTRAINT would be enough, since ALWAYS and
> STORED are the only possible choice anyway. I am a bit uncertain on what
> is best here.
Your point about moving STORED at the end is good, I feel bad about
dropping it completely though, because in the ADD COLUMN subcommand the
default is VIRTUAL when omitted. Maybe we could drop the ALWAYS, since
that is only relevant when talking about identity columns. That would
result in:
... ADD GENERATED USING CONSTRAINT constr_name STORED
Another option would be to be consistent with SET|DROP EXPRESSION:
... ADD EXPRESSION USING CONSTRAINT constr_name STORED
I would not omit STORED here either, because SET|DROP EXPRESSION act on
what's already a generated column (so they don't need to specify the
type at all), but this one does need to know.
The ALTER TABLE page of the manual already groups these 3 together:
ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY
SET GENERATED { ALWAYS | BY DEFAULT }
DROP IDENTITY [ IF EXISTS ]
These forms change whether a column is an identity column...
Using EXPRESSION for this new command would result in a similar group:
ADD EXPRESSION USING CONSTRAINT constr_name STORED
SET EXPRESSION AS
DROP EXPRESSION [ IF EXISTS ]
the three forms change whether a column is a generated column or change
the generator expression. The first and the third only work with stored
generated columns.
But at this point we are just debating whether to say GENERATED or
EXPRESSION. Personally I find both OK with a slight preference for the
latter.
>> > I feel that automatically dropping the constraint is a bit too much
>> > black magic, but it is more a feeling than a conviction.
>>
>> I don't have a strong opinion on whether to cleanup or not, I'll gladly
>> take your input. This version of the patch does not drop the constraint
>> anymore.
>
> I like it that way, because ALTER TABLE ATTACH PARTITION and ALTER TABLE
> ALTER COLUMN SET NOT NULL don't drop the constraint they use either.
> But the case is not exactly the same, so I won't insist.
It's a valid point, I would leave it like this if there are no further
objections.
All your feedback about docs, error messages and comment is valid too, I
will gladly adapt in the next iteration.
> There is a weird asymmetry in that the order in which you write the check
> constraint matters:
>
> CREATE TABLE tab (a integer PRIMARY KEY, b integer NOT NULL);
> INSERT INTO tab VALUES (1, 2);
>
> -- this fails
> ALTER TABLE tab ADD CONSTRAINT c CHECK (2 * a = b);
> ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
> ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
> DETAIL: could not find a valid constraint "c" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
>
> -- but this works
> ALTER TABLE tab DROP CONSTRAINT c;
> ALTER TABLE tab ADD CONSTRAINT c CHECK (b = 2 * a);
> ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
>
> I think that both variants should be accepted, but I am not certain.
My original approach here was to be as literal as possible with the
shape of the constraint required to perform this operation. It is after
all an extremely ad-hoc constraint, added just for the purpose of doing
this online migration. But just checking both directions for the
equalities seems simple enough, I'll try to do this in the next version
of the patch.
>> + }
>> + case AttrDefaultRelationId:
>> + {
>> + ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
>> +
>> + if (col.objectId == RelationGetRelid(rel) &&
>> + col.objectSubId == attnum)
>> + {
>> + /*
>> + * Ignore the column's own default expression. We
>> + * handle sequences above, and for a column which is
>> + * already a generated column we should never get
>> + * here.
>> + */
>
> It's not strictly required, but it would be great if you could run pgindent
> to get comments and other parts of the code formatted properly.
This is strange, I did run pgindent. Which part seems formatted
incorrectly to you? I'll try to see if we're hitting some edge case that
makes pgindent skip the code.
>> + * If a valid constraint is found, this returns both the Oid of the constraint
>> + * and the unpacked expression.
>> + */
>> +static Node *
>> +findUsableConstraintForAddExprStored(Relation rel, AttrNumber attnum,
>> + bool attisnotnull,
>> + const char *conname)
>
> I see that it returns a Node, not an Oid and an expression.
> If the Oid of the constraint is actually returned somewhere inside the
> "Node", the comment should be more specific about it.
Oops, this is a leftover from when I still returned the Oid of the
constraint to be able to drop it later. Will fix.
Thanks again for your feedback!
Regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-07-10 06:51 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-07-10 06:51 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Fri, 2026-07-10 at 02:10 +0200, Alberto Piai wrote:
> > The syntax you ended up with (ADD GENERATED ALWAYS STORED USING CONSTRAINT)
> > is ugly as hell. I see your point in having ALWAYS and STORED, but perhaps
> > ADD GENERATED ALWAYS USING CONSTRAINT ... STORED would be better, as it is
> > syntactically more like GENERATED ALWAYS AS (...) STORED, which would make
> > it easier to remember.
> >
> > Or perhaps ADD GENERATED USING CONSTRAINT would be enough, since ALWAYS and
> > STORED are the only possible choice anyway. I am a bit uncertain on what
> > is best here.
>
> Your point about moving STORED at the end is good, I feel bad about
> dropping it completely though, because in the ADD COLUMN subcommand the
> default is VIRTUAL when omitted. Maybe we could drop the ALWAYS, since
> that is only relevant when talking about identity columns. That would
> result in:
>
> ... ADD GENERATED USING CONSTRAINT constr_name STORED
>
> Another option would be to be consistent with SET|DROP EXPRESSION:
>
> ... ADD EXPRESSION USING CONSTRAINT constr_name STORED
>
> I would not omit STORED here either, because SET|DROP EXPRESSION act on
> what's already a generated column (so they don't need to specify the
> type at all), but this one does need to know.
>
> The ALTER TABLE page of the manual already groups these 3 together:
>
> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY
> SET GENERATED { ALWAYS | BY DEFAULT }
> DROP IDENTITY [ IF EXISTS ]
>
> These forms change whether a column is an identity column...
>
> Using EXPRESSION for this new command would result in a similar group:
>
> ADD EXPRESSION USING CONSTRAINT constr_name STORED
> SET EXPRESSION AS
> DROP EXPRESSION [ IF EXISTS ]
>
> the three forms change whether a column is a generated column or change
> the generator expression. The first and the third only work with stored
> generated columns.
>
> But at this point we are just debating whether to say GENERATED or
> EXPRESSION. Personally I find both OK with a slight preference for the
> latter.
I take your point about keeping the STORED. If VIRTUAL is the default
when creating a generated column, then keeping the explicit STORED makes
sense.
About GENERATED vs. EXPRESSION: my feelings go the other way. If I read
ALTER TABLE tab ALTER col ADD EXPRESSION ...
then it is not clear to me *what* expression is added. After all, there
are not only generation expressions, but also column default expressions,
and I think it is good to make clear that this is about generated columns.
I think I understand your preference: after all, we are not ADDing a
GENERATED column, but only an EXPRESSION that turns a regular column
into a generated column. Perhaps I can convince you by showing the
similar
ALTER TABLE tab ALTER col ADD GENERATED ALWAYS AS IDENTITY;
Here, too, we are not adding a new generated column, only turning a
regular column into a generated one. I think it is good to stay as
close to that already established syntax as possible.
> > >
> > There is a weird asymmetry in that the order in which you write the check
> > constraint matters:
> >
> > CREATE TABLE tab (a integer PRIMARY KEY, b integer NOT NULL);
> > INSERT INTO tab VALUES (1, 2);
> >
> > -- this fails
> > ALTER TABLE tab ADD CONSTRAINT c CHECK (2 * a = b);
> > ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
> > ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
> > DETAIL: could not find a valid constraint "c" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM (expr))
> >
> > -- but this works
> > ALTER TABLE tab DROP CONSTRAINT c;
> > ALTER TABLE tab ADD CONSTRAINT c CHECK (b = 2 * a);
> > ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT c;
> >
> > I think that both variants should be accepted, but I am not certain.
>
> My original approach here was to be as literal as possible with the
> shape of the constraint required to perform this operation. It is after
> all an extremely ad-hoc constraint, added just for the purpose of doing
> this online migration. But just checking both directions for the
> equalities seems simple enough, I'll try to do this in the next version
> of the patch.
Right; I thought that it was no big deal. And this would be the only case
in the PostgreSQL SQL dialect that I know where the order of the operands
for an equality condition matters.
> > > + /*
> > > + * Ignore the column's own default expression. We
> > > + * handle sequences above, and for a column which is
> > > + * already a generated column we should never get
> > > + * here.
> > > + */
> >
> > It's not strictly required, but it would be great if you could run pgindent
> > to get comments and other parts of the code formatted properly.
>
> This is strange, I did run pgindent. Which part seems formatted
> incorrectly to you? I'll try to see if we're hitting some edge case that
> makes pgindent skip the code.
I had not tested it, but the lines seemed to be shorter than the 80 columns
to which pgindent formats comments.
If you already ran pgindent, please ignore my comment.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-06 12:36 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-08-06 12:36 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
Hi,
On Fri Jul 10, 2026 at 8:51 AM CEST, Laurenz Albe wrote:
> I take your point about keeping the STORED. If VIRTUAL is the default
> when creating a generated column, then keeping the explicit STORED makes
> sense.
>
> About GENERATED vs. EXPRESSION: my feelings go the other way. If I read
>
> ALTER TABLE tab ALTER col ADD EXPRESSION ...
>
> then it is not clear to me *what* expression is added. After all, there
> are not only generation expressions, but also column default expressions,
> and I think it is good to make clear that this is about generated columns.
>
> I think I understand your preference: after all, we are not ADDing a
> GENERATED column, but only an EXPRESSION that turns a regular column
> into a generated column. Perhaps I can convince you by showing the
> similar
>
> ALTER TABLE tab ALTER col ADD GENERATED ALWAYS AS IDENTITY;
>
> Here, too, we are not adding a new generated column, only turning a
> regular column into a generated one. I think it is good to stay as
> close to that already established syntax as possible.
Yes that is convincing.
PFA v7, implementing this version of the command:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
Differences from v6, besides the new syntax:
- both the = and the not-distinct form of the constraints now work with
both orders of the operands
- comments and error messages changed to address the feedback from the
latest review round
- simplified tests a bit, trying to avoid creating/dropping test tables
unnecessarily
- ATPrepAddGenStore doesn't try to lock child tables anymore, following
what was done for DROP EXPRESSION in
fef160d8b0ddf72e1089133300d30109293f5a71 [0]
In particular, all the error messages now follow the guidelines for
error reporting. I tried to use a consistent error message everywhere,
adding details and hints where appropriate.
All the error cases are grouped together now in the regress suite, so
looking at the expected output should give a good overview of all the
error conditions and how they are reported to the user.
A note about this one:
> The following error message is not very helpful:
>
>
> CREATE TABLE tab (
> a integer DEFAULT 2,
> b integer
> CONSTRAINT con CHECK (b IS NOT DISTINCT FROM 2 + random())
> );
>
>
> ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT con;
> ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
> DETAIL: could not find a valid constraint "con" CHECK ("b" IS NOT DISTINCT FROM (expr))
This was interesting. What's going on here is that since random()
returns a float, the whole expression returns a float. The column b is
an int, so since there is an implicit cast from int to float, the
resulting expression for the CHECK constraint is
b::float IS NOT DISTINCT FROM 2 + random()
I think in cases like this there's not much I can do: the constraint
isn't an equality to b anymore, but an equality to f(b) where f is
the function defined for the cast. The constraint is simply not usable
for our purpose.
For this reason, I think it doesn't make too much sense in this case to
look at the other operand, hunt down the random() and complain about the
function being volatile: the core problem here is the return type, and
the same situation can happen with an immutable function.
I tried detecting implicit casts though, because I think this is a
mistake that's quite easy to make, so it's worth trying to give a hint
to the user about what's going on and what to do.
This is now reported in this way (from the regress test suite):
alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
-- the hint should inform about the type cast
alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
ERROR: cannot convert column "b" to generated
DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
HINT: Ensure that the type of the expression matches the type of the column.
In this situation, \d would show the constraint being
CHECK (b::double precision = (.... expr with random())
instead of b = ...expr, which makes me think the hint is clear enough.
But I'm curious to hear what you think about it.
Independently from the problem with casts, the immutability of the
generation expression is of course also checked:
alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
alter table tgen.t2 alter column b
add generated using constraint chk_gen stored;
ERROR: generation expression is not immutable
Looking forward to your comments!
Regards,
Alberto
[0] https://www.postgresql.org/message-id/anGRnFgKM6RFmGLm%40alvherre.pgsql
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 7b1e65b48e89a3e8469ecafda9a17d2d80a82414 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 5 Aug 2026 17:27:50 +0200
Subject: [PATCH v7] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 583 ++++++++++++++++++
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 5 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..10a68dd672c 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2fa534413ea..9682e186720 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -334,6 +335,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -791,6 +804,22 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4804,6 +4833,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5128,6 +5158,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5522,6 +5560,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6404,13 +6448,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6730,6 +6784,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8880,6 +8936,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting a column to a stored generated column can only be done on the whole hierarchy at once."),
+ errhint("Use this command on the root table/partition without ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting a column to a stored generated column can only be done on the whole hierarchy at once."),
+ errhint("Use this command on the root table/partition without ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 17035fb4d15..5eeb6ebfe70 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2735,6 +2735,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 190fff7ea0e..620f9b5e566 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2972,13 +2981,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 5c8f9a07b62..b57c742d2dd 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2566,6 +2566,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 25a3ddd890d..5c4811664f3 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aaf0536ba7e..b6b240b0fe9 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..535d3ebad5f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..b7a7b713ee0 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..ee7e2903443 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 85d989f395d..e860833749d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: fd2b89854d93d70fe8c9a69d5b8fafd5b9302cfc
--
2.47.0
Attachments:
[text/plain] v7-0001-Support-changing-a-column-into-a-stored-generated.patch (71.6K, ../../DKHUPZ08JV6G.2938OV580K72C@gmail.com/2-v7-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From 7b1e65b48e89a3e8469ecafda9a17d2d80a82414 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 5 Aug 2026 17:27:50 +0200
Subject: [PATCH v7] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 583 ++++++++++++++++++
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 5 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..10a68dd672c 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2fa534413ea..9682e186720 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -334,6 +335,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -791,6 +804,22 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4804,6 +4833,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5128,6 +5158,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5522,6 +5560,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6404,13 +6448,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6730,6 +6784,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8880,6 +8936,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting a column to a stored generated column can only be done on the whole hierarchy at once."),
+ errhint("Use this command on the root table/partition without ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting a column to a stored generated column can only be done on the whole hierarchy at once."),
+ errhint("Use this command on the root table/partition without ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 17035fb4d15..5eeb6ebfe70 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2735,6 +2735,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 190fff7ea0e..620f9b5e566 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2972,13 +2981,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 5c8f9a07b62..b57c742d2dd 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2566,6 +2566,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 25a3ddd890d..5c4811664f3 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aaf0536ba7e..b6b240b0fe9 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..535d3ebad5f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..b7a7b713ee0 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting a column to a stored generated column can only be done on the whole hierarchy at once.
+HINT: Use this command on the root table/partition without ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..ee7e2903443 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 85d989f395d..e860833749d 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: fd2b89854d93d70fe8c9a69d5b8fafd5b9302cfc
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-08-24 17:29 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-08-24 17:29 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Thu, 2026-08-06 at 14:36 +0200, Alberto Piai wrote:
> PFA v7, implementing this version of the command:
>
> ... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
Great! I think this is pretty much good to go.
> In particular, all the error messages now follow the guidelines for
> error reporting. I tried to use a consistent error message everywhere,
> adding details and hints where appropriate.
Much better!
I was wondering about this hint:
Use this command on the root table/partition without ONLY.
You can never turn a column of a partition into a generated column, right?
How about
Use the command on the partition root without specifying ONLY.
This detail message is longer than 80 characters:
Converting a column to a stored generated column can only be done on the whole hierarchy at once.
How about
Converting only part of a partitioning/inheritance hierarchy is not supported.
> A note about this one:
>
> > The following error message is not very helpful:
> >
> >
> > CREATE TABLE tab (
> > a integer DEFAULT 2,
> > b integer
> > CONSTRAINT con CHECK (b IS NOT DISTINCT FROM 2 + random())
> > );
> >
> >
> > ALTER TABLE tab ALTER b ADD GENERATED ALWAYS STORED USING CONSTRAINT con;
> > ERROR: cannot convert a column into a stored generated column without a constraint to prove that the values are consistent
> > DETAIL: could not find a valid constraint "con" CHECK ("b" IS NOT DISTINCT FROM (expr))
>
> This was interesting. What's going on here is that since random()
> returns a float, the whole expression returns a float. The column b is
> an int, so since there is an implicit cast from int to float, the
> resulting expression for the CHECK constraint is
>
> b::float IS NOT DISTINCT FROM 2 + random()
>
> I think in cases like this there's not much I can do: the constraint
> isn't an equality to b anymore, but an equality to f(b) where f is
> the function defined for the cast. The constraint is simply not usable
> for our purpose.
>
> For this reason, I think it doesn't make too much sense in this case to
> look at the other operand, hunt down the random() and complain about the
> function being volatile: the core problem here is the return type, and
> the same situation can happen with an immutable function.
>
> I tried detecting implicit casts though, because I think this is a
> mistake that's quite easy to make, so it's worth trying to give a hint
> to the user about what's going on and what to do.
>
> This is now reported in this way (from the regress test suite):
>
> alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
> -- the hint should inform about the type cast
> alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
> ERROR: cannot convert column "b" to generated
> DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
> HINT: Ensure that the type of the expression matches the type of the column.
>
> In this situation, \d would show the constraint being
>
> CHECK (b::double precision = (.... expr with random())
>
> instead of b = ...expr, which makes me think the hint is clear enough.
> But I'm curious to hear what you think about it.
>
> Independently from the problem with casts, the immutability of the
> generation expression is of course also checked:
>
> alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
> alter table tgen.t2 alter column b
> add generated using constraint chk_gen stored;
> ERROR: generation expression is not immutable
I agree with your assessment; thanks for the additional hint!
There is one sentence in the documentation that sounds wrong to me:
+ <para>
+ After this command is run, <literal>column_name</literal> will be a stored
+ generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
Shouldn't it be "after this command has been run"? Or perhaps "has completed"?
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-24 20:03 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-08-24 20:03 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
Hi Laurenz,
thanks for your feedback!
On Mon Aug 24, 2026 at 7:29 PM CEST, Laurenz Albe wrote:
> On Thu, 2026-08-06 at 14:36 +0200, Alberto Piai wrote:
>> PFA v7, implementing this version of the command:
>>
>> ... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
>
> Great! I think this is pretty much good to go.
>
>> In particular, all the error messages now follow the guidelines for
>> error reporting. I tried to use a consistent error message everywhere,
>> adding details and hints where appropriate.
>
> Much better!
>
> I was wondering about this hint:
>
> Use this command on the root table/partition without ONLY.
>
> You can never turn a column of a partition into a generated column, right?
> How about
>
> Use the command on the partition root without specifying ONLY.
Since this is also taking care of classic inheritance, it seems strange
to only mention partitions. But I see that this way it's confusing (and
I do concede that partitioning might be the more widely used of the two)
so I switched it around to "on the root partition/table without
specifying ONLY". Does that make more sense?
> This detail message is longer than 80 characters:
>
> Converting a column to a stored generated column can only be done on the whole hierarchy at once.
>
> How about
>
> Converting only part of a partitioning/inheritance hierarchy is not supported.
Much better, fixed.
> There is one sentence in the documentation that sounds wrong to me:
>
> + <para>
> + After this command is run, <literal>column_name</literal> will be a stored
> + generated column with <literal>expr</literal> as its generation
> + expression.
> + </para>
>
> Shouldn't it be "after this command has been run"? Or perhaps "has completed"?
Thanks, changed to "has completed".
The attached v8 addresses the points above.
Kind regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 2bb097c055a69863b0bfa24d1b22a852451e110d Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 5 Aug 2026 17:27:50 +0200
Subject: [PATCH v8] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 583 ++++++++++++++++++
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 5 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..4d2b214da31 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 9b911310f05..6b3318d7992 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -335,6 +336,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -792,6 +805,22 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4812,6 +4841,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5136,6 +5166,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5530,6 +5568,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6412,13 +6456,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6738,6 +6792,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8897,6 +8953,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning/inheritance hierarchy is not supported."),
+ errhint("Use this command on the root partition/table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning/inheritance hierarchy is not supported."),
+ errhint("Use this command on the root partition/table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 17035fb4d15..5eeb6ebfe70 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2735,6 +2735,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 190fff7ea0e..620f9b5e566 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2972,13 +2981,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 8a9df884276..48a9e781469 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2566,6 +2566,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..535d3ebad5f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..b0565c3ef4f 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..d1b684e8f00 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6c366d3a523..263fedc72a7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 04a20a4a1b02ac12eecde28e441e0a20e5063382
--
2.47.0
Attachments:
[text/plain] v8-0001-Support-changing-a-column-into-a-stored-generated.patch (71.5K, ../../DKXFEG915LK8.12HECZQW2OWQE@gmail.com/2-v8-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From 2bb097c055a69863b0bfa24d1b22a852451e110d Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 5 Aug 2026 17:27:50 +0200
Subject: [PATCH v8] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 583 ++++++++++++++++++
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 5 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..4d2b214da31 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -52,6 +52,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -272,6 +273,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 9b911310f05..6b3318d7992 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -101,6 +101,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -335,6 +336,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -792,6 +805,22 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
Relation rel, PartitionCmd *cmd,
AlterTableUtilityContext *context);
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
static List *collectPartitionIndexExtDeps(List *partitionOids);
static void applyPartitionIndexExtDeps(Oid newPartOid, List *extDepState);
static void freePartitionIndexExtDeps(List *extDepState);
@@ -4812,6 +4841,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5136,6 +5166,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5530,6 +5568,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6412,13 +6456,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6738,6 +6792,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8897,6 +8953,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning/inheritance hierarchy is not supported."),
+ errhint("Use this command on the root partition/table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning/inheritance hierarchy is not supported."),
+ errhint("Use this command on the root partition/table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 17035fb4d15..5eeb6ebfe70 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2735,6 +2735,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 190fff7ea0e..620f9b5e566 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2972,13 +2981,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 8a9df884276..48a9e781469 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2566,6 +2566,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..535d3ebad5f 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..b0565c3ef4f 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning/inheritance hierarchy is not supported.
+HINT: Use this command on the root partition/table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..d1b684e8f00 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6c366d3a523..263fedc72a7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 04a20a4a1b02ac12eecde28e441e0a20e5063382
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-08-25 08:46 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-08-25 08:46 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Mon, 2026-08-24 at 22:03 +0200, Alberto Piai wrote:
> > I was wondering about this hint:
> >
> > Use this command on the root table/partition without ONLY.
> >
> > You can never turn a column of a partition into a generated column, right?
> > How about
> >
> > Use the command on the partition root without specifying ONLY.
>
> Since this is also taking care of classic inheritance, it seems strange
> to only mention partitions. But I see that this way it's confusing (and
> I do concede that partitioning might be the more widely used of the two)
> so I switched it around to "on the root partition/table without
> specifying ONLY". Does that make more sense?
My problem is that there is no "root partition".
Perhaps
Use the command on the partitioning or inheritance root without specifying ONLY.
> > This detail message is longer than 80 characters:
> >
> > Converting a column to a stored generated column can only be done on the whole hierarchy at once.
> >
> > How about
> >
> > Converting only part of a partitioning/inheritance hierarchy is not supported.
>
> Much better, fixed.
I am having second thoughts about the slash.
Perhaps it would be better to use "or" instead.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-25 16:13 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-08-25 16:13 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Tue Aug 25, 2026 at 10:46 AM CEST, Laurenz Albe wrote:
> On Mon, 2026-08-24 at 22:03 +0200, Alberto Piai wrote:
>> > I was wondering about this hint:
>> >
>> > Use this command on the root table/partition without ONLY.
>> >
>> > You can never turn a column of a partition into a generated column, right?
>> > How about
>> >
>> > Use the command on the partition root without specifying ONLY.
>>
>> Since this is also taking care of classic inheritance, it seems strange
>> to only mention partitions. But I see that this way it's confusing (and
>> I do concede that partitioning might be the more widely used of the two)
>> so I switched it around to "on the root partition/table without
>> specifying ONLY". Does that make more sense?
>
> My problem is that there is no "root partition".
Ah, I see what you mean now. I agree, "root partition" makes no sense.
So, removing the slash, it would look like:
ERROR: cannot convert column "c" to generated
DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
HINT: Use this command on the partitioning or inheritance root without specifying ONLY.
Maybe a bit heavy on the eye?
Since the root of the hierarchy is in both cases just a table
(partitoned or not), what about just calling it a table?
ERROR: cannot convert column "c" to generated
DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
HINT: Use this command on the root table without specifying ONLY.
Regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-08-26 05:20 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-08-26 05:20 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Tue, 2026-08-25 at 18:13 +0200, Alberto Piai wrote:
> Ah, I see what you mean now. I agree, "root partition" makes no sense.
>
> So, removing the slash, it would look like:
>
> ERROR: cannot convert column "c" to generated
> DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
> HINT: Use this command on the partitioning or inheritance root without specifying ONLY.
>
> Maybe a bit heavy on the eye?
>
> Since the root of the hierarchy is in both cases just a table
> (partitoned or not), what about just calling it a table?
>
> ERROR: cannot convert column "c" to generated
> DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
> HINT: Use this command on the root table without specifying ONLY.
Both variants look fine to me. Pick the one you prefer.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-28 05:50 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-08-28 05:50 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Wed Aug 26, 2026 at 7:20 AM CEST, Laurenz Albe wrote:
>> Since the root of the hierarchy is in both cases just a table
>> (partitoned or not), what about just calling it a table?
>>
>> ERROR: cannot convert column "c" to generated
>> DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
>> HINT: Use this command on the root table without specifying ONLY.
>
> Both variants look fine to me. Pick the one you prefer.
OK, I went with "table" then.
Here's v9, rebased on top of current master.
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 30cdea0f7a2bc9b0ce38f15641705382c130dd2e Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 28 Aug 2026 07:38:59 +0200
Subject: [PATCH v9] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 584 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 6 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..83c2ea6c2a1 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..7f539fd848a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -333,6 +334,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -763,7 +776,22 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4781,6 +4809,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5129,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5488,6 +5525,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6354,13 +6397,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6680,6 +6733,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8835,6 +8890,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index a33c3aaeeb2..7531cb23e52 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2683,6 +2683,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1b74fa62c5c..c7a2b14e10e 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2971,13 +2980,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index d3835af8f9e..4910aa2201a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2540,6 +2540,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..c54a763fdce 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..d1b684e8f00 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 59cf40b5bb0..be65c10a684 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 6e5d5680b555523a3d5c547aa22df0267860344a
--
2.47.0
Attachments:
[text/plain] v9-0001-Support-changing-a-column-into-a-stored-generated.patch (71.4K, ../../DL0C8TG99HCO.1XVPKG2IM0C1S@gmail.com/2-v9-0001-Support-changing-a-column-into-a-stored-generated.patch)
download | inline diff:
From 30cdea0f7a2bc9b0ce38f15641705382c130dd2e Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Fri, 28 Aug 2026 07:38:59 +0200
Subject: [PATCH v9] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation.
If the column is nullable, the constraint must be of the form:
CHECK (column_name IS NOT DISTINCT FROM expr)
if the column is NOT NULL, either of the following is acceptable:
CHECK (column_name IS NOT DISTINCT FROM expr)
CHECK (column_name = expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 37 ++
src/backend/commands/tablecmds.c | 584 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 463 ++++++++++++++
src/test/regress/sql/alter_table.sql | 297 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1523 insertions(+), 6 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..83c2ea6c2a1 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,42 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ If the column being modified is nullable, the constraint must be of the
+ form <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ If the column is <literal>NOT NULL</literal>, then the form
+ <literal>CHECK (column_name = expr)</literal> is also allowed.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..7f539fd848a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -333,6 +334,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -763,7 +776,22 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ bool attisnotnull,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4781,6 +4809,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5129,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5488,6 +5525,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6354,13 +6397,23 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+#endif
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+#ifdef USE_INJECTION_POINTS
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+#endif
+ }
if (newrel)
{
@@ -6680,6 +6733,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8835,6 +8890,533 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape.
+ *
+ * If the column is nullable:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If the column is NOT NULL, any of:
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ * CHECK (column = expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ bool attisnotnull, const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ /* If the column is NOT NULL, try to match = as well */
+ else if (attisnotnull && IsA(conexpr, OpExpr))
+ {
+ OpExpr *op = (OpExpr *) conexpr;
+
+ if (list_length(op->args) == 2)
+ {
+ foundExpr = matchBinaryOpOnVar(op->args, attnum, op->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ attTup->attnotnull,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ attTup->attnotnull ?
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" = expr) or CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName, colName) :
+ errdetail("Could not find a valid constraint \"%s\" CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index a33c3aaeeb2..7531cb23e52 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2683,6 +2683,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1b74fa62c5c..c7a2b14e10e 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2971,13 +2980,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index d3835af8f9e..4910aa2201a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2540,6 +2540,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..427f1d1d3ab
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..92393d70f35
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b = a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..c54a763fdce 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,466 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK (b = (a * 2))
+
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | not null | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen_equal" CHECK ((a * 2) = b)
+
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t2;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+ Table "tgen.t2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+select * from tgen.t2 order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+drop table tgen.t2;
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+rollback;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.tpart;
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+ERROR: relation "tgen.tpart" does not exist
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" = expr) or CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..d1b684e8f00 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,300 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- when the target column is NOT NULL, we also accept =
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (b = a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+-- test that we accept = with the operands swapped, too
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen_equal check (a * 2 = b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_equal stored;
+\d tgen.t1
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks
+create table tgen.t2 (a int);
+select pg_relation_filenode('tgen.t2') as t2_filenode_before \gset
+insert into tgen.t2 select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t2 (a) values (null);
+alter table tgen.t2 add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t2
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t2
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+insert into tgen.t2 (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t2 set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t2 validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t2'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t2'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_skip_rewrite;
+select * from tgen.t2;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t2 (a) values (400);
+drop trigger tgen_trig on tgen.t2;
+drop function tgen.gen();
+insert into tgen.t2 (a) values (500);
+\d tgen.t2
+select * from tgen.t2 order by a nulls first;
+drop table tgen.t2;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering the parent table, recursing
+begin;
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+rollback;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+drop table tgen.tpart;
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a + b);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b = a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b = a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int) partition by hash (coalesce(b, 123));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+show search_path \gset
+set search_path to tgen, public;
+create table t2 (a int, b int) partition by range ((t2));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+create table t2 (a int, b int) partition by range ((t2 is null));
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+-- for a nullable column:
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- for a not null column:
+begin;
+alter table tgen.t1 alter b set not null;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+drop schema tgen;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 59cf40b5bb0..be65c10a684 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 6e5d5680b555523a3d5c547aa22df0267860344a
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-08-28 13:36 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-08-28 13:36 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Fri, 2026-08-28 at 07:50 +0200, Alberto Piai wrote:
> Here's v9, rebased on top of current master.
I can't find any fault with it; will mark it as "ready for committer".
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-28 16:07 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-08-28 16:07 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Fri Aug 28, 2026 at 3:36 PM CEST, Laurenz Albe wrote:
> On Fri, 2026-08-28 at 07:50 +0200, Alberto Piai wrote:
>> Here's v9, rebased on top of current master.
>
> I can't find any fault with it; will mark it as "ready for committer".
Thanks again for all the effort you put into reviewing this, very much
appreciated!
Before this gets eventually picked up by a committer: I am having second
thoughts about my choice to allow = in addition to IS NOT DISTINCT FROM.
The patch can absolutely be reviewed with that in mind, I think.
Removing support for = would be an isolated change which wouldn't affect
the rest of the implementation.
As it stands, I'd probably prefer it committed without that, and expand
on the first commit at a later point.
Anyway I'll try to find time to think about a more convincing proof (or
a counterexample) over the next couple of days.
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-08-29 06:14 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-08-29 06:14 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Fri, 2026-08-28 at 18:07 +0200, Alberto Piai wrote:
> Before this gets eventually picked up by a committer: I am having second
> thoughts about my choice to allow = in addition to IS NOT DISTINCT FROM.
>
> The patch can absolutely be reviewed with that in mind, I think.
> Removing support for = would be an isolated change which wouldn't affect
> the rest of the implementation.
>
> As it stands, I'd probably prefer it committed without that, and expand
> on the first commit at a later point.
>
> Anyway I'll try to find time to think about a more convincing proof (or
> a counterexample) over the next couple of days.
I think I see what you mean: this command exists exclusively so that users
can add a generated column to a bigger table without downtime. So they
will create the constraint specifically for this purpose, and it wouldn't
be a loss of functionality to force them to use IS NOT DISTINCT FROM.
Removing support for a constraint with = would simplify the code and the
documentation.
I won't object to that, but I like the patch as it is now.
I can imagine a case where somebody uses a regular column with a check
constraint and at some later point decides to turn the column into a
generated column. That user might be annoyed if they had to create a
second check constraint, since there already is a perfectly good one.
I'd say that if you remove support for =, you might as well also remove
support for check constraints in the shape "(expression IS NOT DISTINCT
FROM column)".
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-08-31 18:41 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-01 04:58 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 2 replies; 37+ messages in thread
From: Alberto Piai @ 2026-08-31 18:41 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
Hi Laurenz,
On Sat Aug 29, 2026 at 8:14 AM CEST, Laurenz Albe wrote:
> On Fri, 2026-08-28 at 18:07 +0200, Alberto Piai wrote:
>> Before this gets eventually picked up by a committer: I am having second
>> thoughts about my choice to allow = in addition to IS NOT DISTINCT FROM.
>
> I think I see what you mean: this command exists exclusively so that users
> can add a generated column to a bigger table without downtime. So they
> will create the constraint specifically for this purpose, and it wouldn't
> be a loss of functionality to force them to use IS NOT DISTINCT FROM.
> Removing support for a constraint with = would simplify the code and the
> documentation.
>
> I won't object to that, but I like the patch as it is now.
> I can imagine a case where somebody uses a regular column with a check
> constraint and at some later point decides to turn the column into a
> generated column. That user might be annoyed if they had to create a
> second check constraint, since there already is a perfectly good one.
I liked the = form too (but: there is a "but" coming later :)), I always
looked at this from the perspective of a user who is intentionally
running through these steps to perform a very specific migration.
Assuming they don't make mistakes (or the expression is not nullable, as
in the simple case of "b = a + 1" where the referenced a is NOT NULL), =
works fine.
The problem is that it's relatively easy to make a mistake (or a
malicious user could take advantage of it) whenever the expression is
nullable. In that case, a row could be added to the table that still
satisfies the constraint (since CHECK constraints are satisfied when the
expression evaluates to NULL), the alter table would happily run
through, and the db would be left in an inconsistent state.
In the case above of "b = a + 1", if a is nullable, b is NOT NULL and a
row (a, b) with values (null, 42) is inserted, rewriting operations like
update ... set a = a, or pg_dump/pg_restore would fail.
(This can easily be tested manually. I tried to trigger a run on the
GitHub CI to show this, but the pg_upgrade test suite with
PG_TEST_EXTRA=regress_dump_restore doesn't run there.)
I also considered trying to prove non-nullability of the expression
(there is for example expr_is_nonnullable() in clauses.c which does
this), but the attempt failed on the realization that this would really
require full static analysis of arbitrary functions in the expression.
(The source column being NOT NULL is not sufficient, as a function used
in the expression could still decide to return NULL in arbitrary cases.)
So I think this is unfortunately a no-go, even if the form with = looks
a lot more user-friendly.
I have attached v10, which:
- removes support for the = form of the constraints, adapting tests,
documentation and error messages
- removes an unnecessary ifdef I had around the injection points
definitions
- improves the detail message in the case where the constraint is found,
but its shape doesn't match the expected form:
alter table tgen.t1 add constraint chk_gen check (b = a * 2);
alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
ERROR: cannot convert column "b" to generated
-DETAIL: Could not find a valid constraint "chk_gen" CHECK ("b" IS NOT DISTINCT FROM expr).
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
> I'd say that if you remove support for =, you might as well also remove
> support for check constraints in the shape "(expression IS NOT DISTINCT
> FROM column)".
I left this in, as the extra code complexity is really tiny, so I
thought it doesn't hurt much to have it there.
I wish I'd have caught this sooner (especially after having been warned
about this very problem in the first version of the patch) as it would
have saved you some review effort. At least it's fixed before the next
reviews :)
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 216231bb6396fd68994297b6b7222791842b7565 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Mon, 31 Aug 2026 20:23:46 +0200
Subject: [PATCH v10] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation:
CHECK (column_name IS NOT DISTINCT FROM expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 35 ++
src/backend/commands/tablecmds.c | 558 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 414 +++++++++++++
src/test/regress/sql/alter_table.sql | 266 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1415 insertions(+), 6 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..73f70107f7b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,40 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ The constraint must be of the form
+ <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..40c91dd349a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -333,6 +334,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -763,7 +776,21 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4781,6 +4808,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5128,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5488,6 +5524,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6354,13 +6396,19 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+ }
if (newrel)
{
@@ -6680,6 +6728,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8835,6 +8885,512 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape:
+ *
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not of the form CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 2df39a48fc6..b670622b59c 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2683,6 +2683,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1b74fa62c5c..c7a2b14e10e 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2971,13 +2980,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index d3835af8f9e..4910aa2201a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2540,6 +2540,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..215af90c57f
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..d9cf7952ed3
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..07fc101c38e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,417 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t_migration;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+ Table "tgen.t_migration"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Triggers:
+ tgen_trig BEFORE INSERT OR UPDATE ON tgen.t_migration FOR EACH ROW EXECUTE FUNCTION tgen.gen()
+
+select * from tgen.t_migration order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two databases are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_1".
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_2".
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..4fed663fde1 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,269 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+select * from tgen.t_migration;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+select * from tgen.t_migration order by a nulls first;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two databases are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c546b3d6375..8d0a5db654e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 0e944fe3579ecd525e5ffe7b7db4a7e1c34de49e
--
2.47.0
Attachments:
[text/plain] v10-0001-Support-changing-a-column-into-a-stored-generate.patch (68.3K, ../../DL3CJBUV86UG.3IZJ0KV63YKZ2@gmail.com/2-v10-0001-Support-changing-a-column-into-a-stored-generate.patch)
download | inline diff:
From 216231bb6396fd68994297b6b7222791842b7565 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Mon, 31 Aug 2026 20:23:46 +0200
Subject: [PATCH v10] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation:
CHECK (column_name IS NOT DISTINCT FROM expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 35 ++
src/backend/commands/tablecmds.c | 558 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 19 +
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 414 +++++++++++++
src/test/regress/sql/alter_table.sql | 266 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1415 insertions(+), 6 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..73f70107f7b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,40 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ The constraint must be of the form
+ <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..40c91dd349a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -333,6 +334,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -763,7 +776,21 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4781,6 +4808,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5100,6 +5128,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5488,6 +5524,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6354,13 +6396,19 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+ }
if (newrel)
{
@@ -6680,6 +6728,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8835,6 +8885,512 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on sequence \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape:
+ *
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not of the form CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 2df39a48fc6..b670622b59c 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2683,6 +2683,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..edca2d019a9 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -46,6 +46,8 @@ $node->safe_psql('postgres',
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
. "CREATE PUBLICATION some_publication;\n"
. "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
);
# In a VPATH build, we'll be started in the source directory, but we want
@@ -460,6 +462,23 @@ check_completion("FR\t", qr/FROM /,
clear_query();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1b74fa62c5c..c7a2b14e10e 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2971,13 +2980,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index d3835af8f9e..4910aa2201a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2540,6 +2540,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 3b136adf126..ba9a54b29df 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -9,7 +9,7 @@ EXTENSION = injection_points
DATA = injection_points--1.0.sql
PGFILEDESC = "injection_points - facility for injection points"
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..215af90c57f
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index aff516b901a..2a98a1d5a4d 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..d9cf7952ed3
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..07fc101c38e 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,417 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t_migration;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+ Table "tgen.t_migration"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Triggers:
+ tgen_trig BEFORE INSERT OR UPDATE ON tgen.t_migration FOR EACH ROW EXECUTE FUNCTION tgen.gen()
+
+select * from tgen.t_migration order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "cdoesnotexist".
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: current transaction is aborted, commands ignored until end of transaction block
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two databases are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_1".
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_2".
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..4fed663fde1 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,269 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+select * from tgen.t_migration;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+select * from tgen.t_migration order by a nulls first;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+alter table tgen.t1 alter column b
+ add generated using constraint cdoesnotexist stored;
+
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two databases are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c546b3d6375..8d0a5db654e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: 0e944fe3579ecd525e5ffe7b7db4a7e1c34de49e
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-01 04:58 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-01 09:02 ` Re: Adding a stored generated column without long-lived locks solai v <solai.cdac@gmail.com>
1 sibling, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-09-01 04:58 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Mon, 2026-08-31 at 20:41 +0200, Alberto Piai wrote:
> I liked the = form too (but: there is a "but" coming later :)), I always
> looked at this from the perspective of a user who is intentionally
> running through these steps to perform a very specific migration.
> Assuming they don't make mistakes (or the expression is not nullable, as
> in the simple case of "b = a + 1" where the referenced a is NOT NULL), =
> works fine.
>
> The problem is that it's relatively easy to make a mistake (or a
> malicious user could take advantage of it) whenever the expression is
> nullable. In that case, a row could be added to the table that still
> satisfies the constraint (since CHECK constraints are satisfied when the
> expression evaluates to NULL), the alter table would happily run
> through, and the db would be left in an inconsistent state.
>
> In the case above of "b = a + 1", if a is nullable, b is NOT NULL and a
> row (a, b) with values (null, 42) is inserted, rewriting operations like
> update ... set a = a, or pg_dump/pg_restore would fail.
Embarrassing - I should have seen that during my review.
You are right, it's best to forget about check constraints with = here.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-01 04:58 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-09-01 09:02 ` solai v <solai.cdac@gmail.com>
2026-09-01 15:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: solai v @ 2026-09-01 09:02 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; +Cc: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers@postgresql.org, Álvaro Herrera <alvherre@kurilemu.de>
Hi,
I tested v10 of the patch on the latest PostgreSQL master.
The patch applied cleanly, and PostgreSQL built successfully. git diff
--check completed without any issues.
I also ran: make check and all regression tests passed.
For the main functionality, I created a table with 100,000 existing
rows, backfilled the column, and added the required CHECK constraint:
CHECK (generated_value IS NOT DISTINCT FROM id * 2)
The following command then completed successfully:
ALTER TABLE test_gen_v10
ALTER COLUMN generated_value
ADD GENERATED USING CONSTRAINT test_gen_v10_check STORED;
The column was confirmed as: generated always as (id * 2) stored
I also tested the conversion with a table containing 1,000,000 rows.
The relation file path and size remained unchanged before and after
the conversion:
Before:
filepath: base/5/16393
size: 44,285,952 bytes (42 MB)
After:
filepath: base/5/16393
size: 44,285,952 bytes (42 MB)
This indicates that the table was not physically rewritten during the
conversion.
Overall, the v10 patch worked as expected in my testing.
Regards,
solai
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-01 04:58 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-01 09:02 ` Re: Adding a stored generated column without long-lived locks solai v <solai.cdac@gmail.com>
@ 2026-09-01 15:41 ` Alberto Piai <alberto.piai@gmail.com>
0 siblings, 0 replies; 37+ messages in thread
From: Alberto Piai @ 2026-09-01 15:41 UTC (permalink / raw)
To: solai v <solai.cdac@gmail.com>; Laurenz Albe <laurenz.albe@cybertec.at>; +Cc: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; Álvaro Herrera <alvherre@kurilemu.de>
Hello Solai,
On Tue Sep 1, 2026 at 11:02 AM CEST, solai v wrote:
> I tested v10 of the patch on the latest PostgreSQL master.
> The patch applied cleanly, and PostgreSQL built successfully. git diff
>
> Overall, the v10 patch worked as expected in my testing.
Thank you for testing the patch!
Best regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-16 17:02 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
1 sibling, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-09-16 17:02 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; Laurenz Albe <laurenz.albe@cybertec.at>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
CFBot alerted me that a rebase was needed, so here it is in the attached
v11.
I also fixed a couple broken / duplicated tests that had eluded me in my
last pass over this, and shortened the wording in this error message a
bit:
ERROR: cannot convert column "b" to generated
-DETAIL: Column "b" of relation "t2" is a serial column (depends on sequence "sequence tgen.t2_b_seq").
+DETAIL: Column "b" of relation "t2" is a serial column (depends on "sequence tgen.t2_b_seq").
Kind regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
From 4d63833da33ff1a0b6d4d45d93e4316e67c97742 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 16 Sep 2026 18:27:05 +0200
Subject: [PATCH v11] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation:
CHECK (column_name IS NOT DISTINCT FROM expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 35 ++
src/backend/commands/tablecmds.c | 558 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 22 +-
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 410 +++++++++++++
src/test/regress/sql/alter_table.sql | 264 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1411 insertions(+), 7 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..73f70107f7b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,40 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ The constraint must be of the form
+ <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2f073ddb84a..88b6c0fb8dc 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -327,6 +328,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -757,7 +770,21 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4771,6 +4798,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5090,6 +5118,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5478,6 +5514,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6344,13 +6386,19 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+ }
if (newrel)
{
@@ -6670,6 +6718,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8825,6 +8875,512 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape:
+ *
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not of the form CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0563453fe24..55a8807c2de 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2642,6 +2642,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 1d2e5f5b92a..295c40e2e5c 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -44,7 +44,10 @@ $node->safe_psql('postgres',
. "CREATE TABLE mytab246 (f1 int, f2 text);\n"
. "CREATE TABLE \"mixedName\" (f1 int, f2 text);\n"
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
- . "CREATE PUBLICATION some_publication;\n");
+ . "CREATE PUBLICATION some_publication;\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
+);
# In a VPATH build, we'll be started in the source directory, but we want
# to run in the build directory so that we can use relative paths to
@@ -422,6 +425,23 @@ check_completion(
clear_line();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b3bfe050b18..2ce007bced5 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2945,13 +2954,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index aea7311faad..2daf732edb4 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2462,6 +2462,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 9d8b4b3540c..33f0fc758b2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -11,7 +11,7 @@ PGFILEDESC = "injection_points - facility for injection points"
EXTRA_INSTALL = contrib/test_decoding
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..215af90c57f
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 80a09f34d78..a180ee9de43 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..d9cf7952ed3
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..899a0baf573 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,413 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t_migration;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+ Table "tgen.t_migration"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Triggers:
+ tgen_trig BEFORE INSERT OR UPDATE ON tgen.t_migration FOR EACH ROW EXECUTE FUNCTION tgen.gen()
+
+select * from tgen.t_migration order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t1" is already a generated column.
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two tables are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_1".
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_2".
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..966f1e00bf9 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,267 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+select * from tgen.t_migration;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+select * from tgen.t_migration order by a nulls first;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two tables are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 0dc817cc2b8..f98a458d5d2 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: fcc0e27f45e2d8dc6046435908a48626c91d3d84
--
2.47.0
Attachments:
[text/plain] v11-0001-Support-changing-a-column-into-a-stored-generate.patch (68.2K, ../../DLGVZTSLAPS8.39J305UANRWN@gmail.com/2-v11-0001-Support-changing-a-column-into-a-stored-generate.patch)
download | inline diff:
From 4d63833da33ff1a0b6d4d45d93e4316e67c97742 Mon Sep 17 00:00:00 2001
From: Alberto Piai <alberto.piai@gmail.com>
Date: Wed, 16 Sep 2026 18:27:05 +0200
Subject: [PATCH v11] Support changing a column into a stored generated column
This adds an ALTER TABLE subcommand which turns a regular column into a
stored generated column:
... ALTER col ADD GENERATED USING CONSTRAINT constr_name STORED
The main purpose of this command is to make it possible to add a stored
generated column without rewriting the table under an AccessExclusive
lock.
Before running this command, the table should have been prepared by
adding a regular column, backfilling it with data matching the intended
generation expression, and adding the constr_name constraint to prove
that the data does satisfy said generation expression.
The constraint must have a specific structure to be usable for this
operation:
CHECK (column_name IS NOT DISTINCT FROM expr)
The column will then be changed into a stored generated column, with the
"expr" from the constraint as its generator expression. The operation
will be performed without rewriting the table, and without any
verification scan.
The syntax is chosen for its similarity to:
... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY
with the difference that:
- since we are not talking about IDENTITY columns, ALWAYS is implicit
and does not need to be explicitly supplied
- STORED must always be supplied, to make it clear that we are talking
about stored and not virtual generated columns (VIRTUAL is the default
when omitted in other commands, so we mandate STORED here to be
consistent)
This new operation fits together with SET EXPRESSION and DROP
EXPRESSION: the latter works in the opposite direction, turning a
generated column into a regular column.
Partitioning/inheritance is supported in the same way as DROP
EXPRESSION: it is allowed to change the whole inheritace tree to/from a
generated column at once; it is forbidden to change the parent table
ONLY and it is forbidden to change a partition directly. See
8bf6ec3ba3a44448817af47a080587f3b71bee08 and the associated discussion.
---
doc/src/sgml/ref/alter_table.sgml | 35 ++
src/backend/commands/tablecmds.c | 558 +++++++++++++++++-
src/backend/parser/gram.y | 18 +
src/bin/psql/t/010_tab_completion.pl | 22 +-
src/bin/psql/tab-complete.in.c | 43 +-
src/include/nodes/parsenodes.h | 1 +
src/test/modules/injection_points/Makefile | 2 +-
.../injection_points/expected/alter_table.out | 36 ++
src/test/modules/injection_points/meson.build | 1 +
.../injection_points/sql/alter_table.sql | 24 +
.../test_ddl_deparse/test_ddl_deparse.c | 3 +
src/test/regress/expected/alter_table.out | 410 +++++++++++++
src/test/regress/sql/alter_table.sql | 264 +++++++++
src/tools/pgindent/typedefs.list | 1 +
14 files changed, 1411 insertions(+), 7 deletions(-)
create mode 100644 src/test/modules/injection_points/expected/alter_table.out
create mode 100644 src/test/modules/injection_points/sql/alter_table.sql
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..73f70107f7b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -46,6 +46,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET DEFAULT <replaceable class="parameter">expression</replaceable>
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP DEFAULT
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> { SET | DROP } NOT NULL
+ ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED USING CONSTRAINT <replaceable class="parameter">constraint_name</replaceable> STORED
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> SET EXPRESSION AS ( <replaceable class="parameter">expression</replaceable> )
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> DROP EXPRESSION [ IF EXISTS ]
ALTER [ COLUMN ] <replaceable class="parameter">column_name</replaceable> ADD GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( <replaceable>sequence_options</replaceable> ) ]
@@ -266,6 +267,40 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
</listitem>
</varlistentry>
+ <varlistentry id="sql-altertable-desc-add-generated-always-stored-using-constraint">
+ <term><literal>ADD GENERATED USING CONSTRAINT</literal></term>
+ <listitem>
+ <para>
+ This form changes a regular column into a stored generated column, using
+ the expression from the given <literal>CHECK</literal> constraint as
+ generation expression. The operation will be performed without rewriting
+ the table, which avoids holding an <literal>ACCESS EXCLUSIVE</literal>
+ lock for a possibly long time.
+ </para>
+
+ <para>
+ Before using this command, the table will usually have been prepared by
+ adding a regular column, backfilling it with values matching the intended
+ generation expression and adding a constraint to ensure that the
+ generation expression is satisfied. Note that the constraint can also be
+ added without holding an <literal>ACCESS EXCLUSIVE</literal> lock while
+ the table is scanned, using <literal>NOT VALID</literal> and
+ <literal>VALIDATE CONSTRAINT</literal>.
+ </para>
+
+ <para>
+ The constraint must be of the form
+ <literal>CHECK (column_name IS NOT DISTINCT FROM expr)</literal>.
+ </para>
+
+ <para>
+ After this command has completed, <literal>column_name</literal> will be a
+ stored generated column with <literal>expr</literal> as its generation
+ expression.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-altertable-desc-set-expression">
<term><literal>SET EXPRESSION AS</literal></term>
<listitem>
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2f073ddb84a..88b6c0fb8dc 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -99,6 +99,7 @@
#include "utils/acl.h"
#include "utils/builtins.h"
#include "utils/fmgroids.h"
+#include "utils/injection_point.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
@@ -327,6 +328,18 @@ struct DropRelationCallbackState
char actual_relpersistence;
};
+/*
+ * Used by findUsableConstraintForAddGenStored to give a hint about why no
+ * constraint matched.
+ */
+typedef enum AddGenConstrError
+{
+ ADD_GEN_CONSTR_NOT_FOUND = 0,
+ ADD_GEN_CONSTR_NOT_VALID,
+ ADD_GEN_CONSTR_SHAPE_MISMATCH,
+ ADD_GEN_CONSTR_TYPE_CAST,
+} AddGenConstrError;
+
/* Alter table target-type flags for ATSimplePermissions */
#define ATT_TABLE 0x0001
#define ATT_VIEW 0x0002
@@ -757,7 +770,21 @@ static List *GetParentedForeignKeyRefs(Relation partition);
static void ATDetachCheckNoForeignKeyRefs(Relation partition);
static char GetAttributeCompression(Oid atttypid, const char *compression);
static char GetAttributeStorage(Oid atttypid, const char *storagemode);
-
+static void ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing);
+static void checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName);
+static Node *matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason);
+static Node *findUsableConstraintForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason);
+static Node *reconstructRawExpr(Relation rel, Node *cookedExpr);
+static ObjectAddress ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def);
/* ----------------------------------------------------------------
* DefineRelation
@@ -4771,6 +4798,7 @@ AlterTableGetLockLevel(List *cmds)
case AT_AddIdentity:
case AT_DropIdentity:
case AT_SetIdentity:
+ case AT_AddGeneratedStored:
case AT_SetExpression:
case AT_DropExpression:
case AT_SetCompression:
@@ -5090,6 +5118,14 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
pass = AT_PASS_SET_EXPRESSION;
break;
+ case AT_AddGeneratedStored: /* ALTER COLUMN ADD GENERATED USING
+ * CONSTRAINT */
+ ATSimplePermissions(cmd->subtype, rel,
+ ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
+ ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+ ATPrepAddGenStored(rel, cmd, recurse, recursing);
+ pass = AT_PASS_ADD_OTHERCONSTR;
+ break;
case AT_DropExpression: /* ALTER COLUMN DROP EXPRESSION */
ATSimplePermissions(cmd->subtype, rel,
ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_FOREIGN_TABLE);
@@ -5478,6 +5514,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
case AT_SetExpression:
address = ATExecSetExpression(tab, rel, cmd->name, cmd->def, lockmode);
break;
+ case AT_AddGeneratedStored:
+ Assert(IsA(cmd->def, Constraint));
+ address = ATExecAddGeneratedStored(tab, rel,
+ cmd->name,
+ (Constraint *) cmd->def);
+ break;
case AT_DropExpression:
address = ATExecDropExpression(rel, cmd->name, cmd->missing_ok, lockmode);
break;
@@ -6344,13 +6386,19 @@ ATRewriteTable(AlteredTableInfo *tab, Oid OIDNewHeap)
}
if (newrel)
+ {
ereport(DEBUG1,
(errmsg_internal("rewriting table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-rewrite", NULL);
+ }
else
+ {
ereport(DEBUG1,
(errmsg_internal("verifying table \"%s\"",
RelationGetRelationName(oldrel))));
+ INJECTION_POINT("alter-table-phase-3-verify", NULL);
+ }
if (newrel)
{
@@ -6670,6 +6718,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
return "ALTER COLUMN ... SET NOT NULL";
case AT_SetExpression:
return "ALTER COLUMN ... SET EXPRESSION";
+ case AT_AddGeneratedStored:
+ return "ALTER COLUMN ... ADD GENERATED STORED";
case AT_DropExpression:
return "ALTER COLUMN ... DROP EXPRESSION";
case AT_SetStatistics:
@@ -8825,6 +8875,512 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
return address;
}
+/*
+ * Preparation phase for
+ *
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * In an inheritance hierarchy, it is only valid to alter the type of the
+ * whole hierarchy at once.
+ */
+static void
+ATPrepAddGenStored(Relation rel, AlterTableCmd *cmd, bool recurse, bool recursing)
+{
+ /*
+ * This routine is called on the top table directly with recursing=false,
+ * and on all children tables via ATSimpleRecursion with recursing=true.
+ *
+ * At the top level, forbid ONLY (i.e. recurse=false) if there are child
+ * tables. We only check this at the top level, otherwise we would prevent
+ * this operation from being applied to hierarchies with depth > 2.
+ *
+ * Note that when we're called with ONLY, ATSimpleRecursion hasn't seen
+ * any child rel yet, but having find_inheritance_children acquire locks
+ * is not necessary. If it found *any* child rel, we'd anyway error out.
+ */
+ if (!recursing && !recurse &&
+ find_inheritance_children(RelationGetRelid(rel), NoLock))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+
+ /*
+ * Don't allow this operation to be applied to inherited columns directly.
+ */
+ if (!recursing)
+ {
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+
+ tuple = SearchSysCacheCopyAttName(RelationGetRelid(rel), cmd->name);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ cmd->name, RelationGetRelationName(rel)));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ if (attTup->attinhcount > 0)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot convert column \"%s\" to generated", cmd->name),
+ errdetail("Converting only part of a partitioning or inheritance hierarchy is not supported."),
+ errhint("Use this command on the root table without specifying ONLY."));
+ }
+}
+
+/*
+ * Detect dependencies which should stop us from turning a regular column
+ * into a stored generated column.
+ */
+static void
+checkDependenciesForAddGenStored(Relation rel,
+ AttrNumber attnum,
+ const char *colName)
+{
+ Relation pg_depend;
+ ScanKeyData keys[3];
+ SysScanDesc scan;
+ HeapTuple depTup;
+
+ pg_depend = table_open(DependRelationId, AccessShareLock);
+
+ ScanKeyInit(&keys[0],
+ Anum_pg_depend_refclassid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationRelationId));
+ ScanKeyInit(&keys[1],
+ Anum_pg_depend_refobjid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(RelationGetRelid(rel)));
+ ScanKeyInit(&keys[2],
+ Anum_pg_depend_refobjsubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(attnum));
+
+ scan = systable_beginscan(pg_depend, DependReferenceIndexId, true,
+ NULL, 3, keys);
+
+ while (HeapTupleIsValid(depTup = systable_getnext(scan)))
+ {
+ Form_pg_depend dep = GETSTRUCT(depTup);
+ ObjectAddress foundObject;
+
+ foundObject.classId = dep->classid;
+ foundObject.objectId = dep->objid;
+ foundObject.objectSubId = dep->objsubid;
+
+ switch (foundObject.classId)
+ {
+ case RelationRelationId:
+ {
+ char relKind = get_rel_relkind(foundObject.objectId);
+
+ /*
+ * While it is possible to alter any sequence to be owned
+ * by an arbitrary column, the most likely legitimate use
+ * is for a serial column. Let's assume this is the case
+ * for the sake of a more helpful error message.
+ */
+ if (relKind == RELKIND_SEQUENCE)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is a serial column (depends on \"%s\").",
+ colName, RelationGetRelationName(rel),
+ getObjectDescription(&foundObject, false))));
+ break;
+ }
+ case AttrDefaultRelationId:
+ {
+ ObjectAddress col = GetAttrDefaultColumnAddress(foundObject.objectId);
+
+ if (col.objectId == RelationGetRelid(rel) &&
+ col.objectSubId == attnum)
+ {
+ /*
+ * Ignore the column's own default expression. We
+ * handle sequences above, and for a column which is
+ * already a generated column we should never get
+ * here.
+ */
+ }
+ else
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced by generated column \"%s\".",
+ colName,
+ get_attname(col.objectId, col.objectSubId, false))));
+ }
+ break;
+ }
+ default:
+ /* Other dependencies aren't a problem. */
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_depend, NoLock);
+}
+
+/*
+ * Given a list of two nodes (operands of a binary function), an operator and a
+ * column, this matches when the operator is an equality and one of the two
+ * operands is a Var referencing the given column.
+ *
+ * It returns the expression tree of the other operand.
+ */
+static Node *
+matchBinaryOpOnVar(List *args, AttrNumber attnum, Oid opno,
+ AddGenConstrError *reason)
+{
+ Node *left,
+ *right;
+
+ Assert(list_length(args) == 2);
+
+ /* Support both orders of the operands */
+ if (IsA(linitial(args), Var))
+ {
+ left = linitial(args);
+ right = lsecond(args);
+ }
+ else
+ {
+ right = linitial(args);
+ left = lsecond(args);
+ }
+
+ if (IsA(left, Var))
+ {
+ Var *var = (Var *) left;
+
+ if (var->varattno == attnum &&
+ op_mergejoinable(opno, exprType((Node *) var)))
+ return right;
+ }
+
+ /*
+ * If we get here, the expression didn't match. Let's try to give a more
+ * specific reason why this was the case.
+ */
+
+ /*
+ * If one side of the operation is an implicit cast, we can guess that we
+ * are dealing with a constraint like CHECK (column = other_column + f())
+ * where f() returns a different type than type of the column, and there
+ * is an implicit cast between the two types. It's easy to make this
+ * mistake, so let's try to give a helpful error message.
+ */
+ if (IsA(left, FuncExpr) || IsA(right, FuncExpr))
+ {
+ FuncExpr *fExpr = (FuncExpr *) (IsA(left, FuncExpr) ? left : right);
+
+ if (list_length(fExpr->args) == 1 &&
+ fExpr->funcformat == COERCE_IMPLICIT_CAST)
+ {
+ *reason = ADD_GEN_CONSTR_TYPE_CAST;
+ }
+ }
+
+ return NULL;
+}
+
+/*
+ * Subroutine for ATExecAddGeneratedStored, used to determine whether the given
+ * constraint proves that the values are equal to some expression.
+ *
+ * Given a rel, a column and a constraint name, we look up a valid CHECK
+ * constraint on the rel, with the given name, with a specific shape:
+ *
+ * CHECK (column IS NOT DISTINCT FROM expr)
+ *
+ * If a valid constraint is found, this returns the expr node, otherwise
+ * it returns null and sets an error code in *reason, allowing the caller to
+ * provide an appropriate error message.
+ */
+static Node *
+findUsableConstraintForAddGenStored(Relation rel, AttrNumber attnum,
+ const char *conname,
+ AddGenConstrError *reason)
+{
+ Relation pg_constraint;
+ HeapTuple conTup;
+ SysScanDesc scan;
+ ScanKeyData key[3];
+ Node *foundExpr;
+
+ pg_constraint = table_open(ConstraintRelationId, AccessShareLock);
+ ScanKeyInit(&key[0],
+ Anum_pg_constraint_conrelid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(rel->rd_id));
+ ScanKeyInit(&key[1],
+ Anum_pg_constraint_contypid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(InvalidOid));
+ ScanKeyInit(&key[2],
+ Anum_pg_constraint_conname,
+ BTEqualStrategyNumber, F_NAMEEQ,
+ CStringGetDatum(conname));
+ scan = systable_beginscan(pg_constraint, ConstraintRelidTypidNameIndexId,
+ true, NULL, 3, key);
+
+ foundExpr = NULL;
+ *reason = ADD_GEN_CONSTR_NOT_FOUND;
+
+ while (HeapTupleIsValid(conTup = systable_getnext(scan)))
+ {
+ Form_pg_constraint con = GETSTRUCT(conTup);
+ char *conbin;
+ Datum val;
+ Node *conexpr;
+
+ if (con->contype != CONSTRAINT_CHECK)
+ continue;
+ /* !conenforced implies !convalidated, but let's be explicit about it */
+ if (!con->convalidated || !con->conenforced)
+ {
+ *reason = ADD_GEN_CONSTR_NOT_VALID;
+ continue;
+ }
+
+ val = SysCacheGetAttrNotNull(CONSTROID, conTup,
+ Anum_pg_constraint_conbin);
+ conbin = TextDatumGetCString(val);
+ conexpr = stringToNode(conbin);
+
+ *reason = ADD_GEN_CONSTR_SHAPE_MISMATCH;
+
+ /* Try to match IS NOT DISTINCT */
+ if (IsA(conexpr, BoolExpr))
+ {
+ BoolExpr *negation = (BoolExpr *) conexpr;
+
+ if (list_length(negation->args) == 1
+ && negation->boolop == NOT_EXPR
+ && IsA(linitial(negation->args), DistinctExpr))
+ {
+ DistinctExpr *dist = linitial(negation->args);
+
+ Assert(list_length(dist->args) == 2);
+
+ foundExpr = matchBinaryOpOnVar(dist->args, attnum, dist->opno, reason);
+ if (foundExpr)
+ break;
+ }
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pg_constraint, AccessShareLock);
+
+ return foundExpr;
+}
+
+/*
+ * Reconstruct a raw expression from a given cooked expression by deparsing it
+ * and running it through raw_parser().
+ */
+static Node *
+reconstructRawExpr(Relation rel, Node *cookedExpr)
+{
+ char *deparsedExpr;
+ List *ctx,
+ *parseResult = NIL;
+
+ ctx = deparse_context_for(RelationGetRelationName(rel),
+ RelationGetRelid(rel));
+
+ deparsedExpr = deparse_expression(cookedExpr, ctx, false, false);
+
+ parseResult = raw_parser(deparsedExpr, RAW_PARSE_PLPGSQL_EXPR);
+ if (list_length(parseResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg("cannot re-parse constraint expr into a raw expression")));
+
+ if (IsA(linitial(parseResult), RawStmt))
+ {
+ RawStmt *stmt = linitial(parseResult);
+
+ if (IsA(stmt->stmt, SelectStmt))
+ {
+ SelectStmt *select = (SelectStmt *) stmt->stmt;
+
+ if (list_length(select->targetList) == 1 &&
+ IsA(linitial(select->targetList), ResTarget))
+ {
+ ResTarget *resTarget = linitial(select->targetList);
+
+ return resTarget->val;
+ }
+ }
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("re-parsed expr does not match the expected structure"));
+}
+
+/*
+ * ALTER COLUMN col ADD GENERATED USING CONSTRAINT name STORED
+ *
+ * Change a regular column into a stored generated column without a table
+ * rewrite, using the expression contained in the given constraint.
+ *
+ * The constraint must be a CHECK constraint proving that the expression is
+ * already satisfied by all the values in the column (see
+ * findUsableConstraintForAddGenStored).
+ */
+static ObjectAddress
+ATExecAddGeneratedStored(AlteredTableInfo *tab,
+ Relation rel,
+ const char *colName,
+ Constraint *def)
+{
+ HeapTuple tuple;
+ Form_pg_attribute attTup;
+ AttrNumber attnum;
+ Bitmapset *colRefs;
+ bool is_expr;
+ ObjectAddress address;
+ Relation pg_attribute;
+ Node *foundConstraintExpr = NULL;
+ AddGenConstrError reason;
+ Node *newRawDefExpr;
+ RawColumnDefault *rawDefault;
+ List *cookedResult = NIL;
+
+ Assert(def->raw_expr == NULL);
+ Assert(def->cooked_expr == NULL);
+ Assert(def->conname != NULL);
+ Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+ Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+ tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+ if (!HeapTupleIsValid(tuple))
+ ereport(ERROR,
+ (errcode(ERRCODE_UNDEFINED_COLUMN),
+ errmsg("column \"%s\" of relation \"%s\" does not exist",
+ colName, RelationGetRelationName(rel))));
+
+ attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+ attnum = attTup->attnum;
+ if (attnum <= 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot alter system column \"%s\"",
+ colName)));
+
+ if (attTup->attidentity)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is an identity column.",
+ colName, RelationGetRelationName(rel))));
+
+ if (attTup->attgenerated)
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" of relation \"%s\" is already a generated column.",
+ colName, RelationGetRelationName(rel))));
+
+ /*
+ * This column might be referenced directly in a partition key, or through
+ * a whole-row expression.
+ */
+ colRefs = bms_make_singleton(attnum - FirstLowInvalidHeapAttributeNumber);
+ colRefs = bms_add_member(colRefs, 0 - FirstLowInvalidHeapAttributeNumber);
+ if (has_partition_attrs(rel, colRefs, &is_expr))
+ ereport(ERROR,
+ (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Column \"%s\" is referenced in the partition key of relation \"%s\".",
+ colName, RelationGetRelationName(rel))));
+
+ checkDependenciesForAddGenStored(rel, attnum, colName);
+
+ /*
+ * Now, try to find the constraint by name, and see if it has the
+ * necessary structure to prove that the values are consistent.
+ */
+ foundConstraintExpr = findUsableConstraintForAddGenStored(rel, attnum,
+ def->conname, &reason);
+ if (foundConstraintExpr == NULL)
+ {
+ if (reason == ADD_GEN_CONSTR_NOT_VALID)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not valid.", def->conname));
+ if (reason == ADD_GEN_CONSTR_SHAPE_MISMATCH || reason == ADD_GEN_CONSTR_TYPE_CAST)
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("The constraint \"%s\" is not of the form CHECK (\"%s\" IS NOT DISTINCT FROM expr).",
+ def->conname, colName),
+ reason == ADD_GEN_CONSTR_TYPE_CAST ?
+ errhint("Ensure that the type of the expression matches the type of the column.") : 0);
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("cannot convert column \"%s\" to generated", colName),
+ errdetail("Could not find CHECK constraint \"%s\".", def->conname));
+ }
+
+ /* Mark as generated stored in pg_attribute */
+ pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+ attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+ CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+ table_close(pg_attribute, RowExclusiveLock);
+
+ ReleaseSysCache(tuple);
+
+ /* Make above changes visible */
+ CommandCounterIncrement();
+
+ /* Recover a raw parse tree for the expression found in the constraint */
+ newRawDefExpr = reconstructRawExpr(rel, foundConstraintExpr);
+
+ /*
+ * Remove previous default value, if any, and store the new generator
+ * expression.
+ */
+ RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+ false, false);
+
+ rawDefault = palloc0_object(RawColumnDefault);
+ rawDefault->attnum = attnum;
+ rawDefault->raw_default = newRawDefExpr;
+ rawDefault->generated = ATTRIBUTE_GENERATED_STORED;
+
+ cookedResult = AddRelationNewConstraints(rel, list_make1(rawDefault), NIL,
+ false /* allow_merge */ ,
+ true /* is_local */ ,
+ false /* is_internal */ ,
+ NULL /* queryString */ );
+
+ if (list_length(cookedResult) != 1)
+ ereport(ERROR,
+ (errcode(ERRCODE_INTERNAL_ERROR),
+ errmsg_internal("cannot store constraint as default value")));
+
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), attnum);
+
+ ObjectAddressSubSet(address, RelationRelationId,
+ RelationGetRelid(rel), attnum);
+ return address;
+}
+
/*
* ALTER TABLE ALTER COLUMN DROP EXPRESSION
*/
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0563453fe24..55a8807c2de 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2642,6 +2642,24 @@ alter_table_cmd:
n->name = $3;
n->def = (Node *) c;
+ $$ = (Node *) n;
+ }
+ /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED USING CONSTRAINT constraint_name STORED */
+ | ALTER opt_column ColId ADD_P GENERATED USING CONSTRAINT name STORED
+ {
+ AlterTableCmd *n = makeNode(AlterTableCmd);
+ Constraint *c = makeNode(Constraint);
+
+ c->conname = $8;
+ c->contype = CONSTR_GENERATED;
+ c->generated_when = ATTRIBUTE_IDENTITY_ALWAYS;
+ c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+ c->location = @8;
+
+ n->subtype = AT_AddGeneratedStored;
+ n->name = $3;
+ n->def = (Node *) c;
+
$$ = (Node *) n;
}
/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 1d2e5f5b92a..295c40e2e5c 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -44,7 +44,10 @@ $node->safe_psql('postgres',
. "CREATE TABLE mytab246 (f1 int, f2 text);\n"
. "CREATE TABLE \"mixedName\" (f1 int, f2 text);\n"
. "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
- . "CREATE PUBLICATION some_publication;\n");
+ . "CREATE PUBLICATION some_publication;\n"
+ . "CREATE TABLE gencol_test (a int primary key, b int);\n"
+ . "ALTER TABLE gencol_test ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM (a + 1));\n"
+);
# In a VPATH build, we'll be started in the source directory, but we want
# to run in the build directory so that we can use relative paths to
@@ -422,6 +425,23 @@ check_completion(
clear_line();
+check_completion("ALTER TABLE gencol_test ALTER COLUMN b A\t", qr/ADD /,
+ "complete ALTER COLUMN <col> ADD");
+
+check_completion("G\t", qr/GENERATED /,
+ "complete ALTER COLUMN <col> ADD GENERATED");
+
+check_completion("U\t", qr/USING CONSTRAINT /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT");
+
+check_completion("\t", qr/check_gen /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT offers check constraint names");
+
+check_completion("S\t", qr/STORED /,
+ "complete ALTER COLUMN <col> ADD GENERATED USING CONSTRAINT constr_name STORED");
+
+clear_query();
+
# send psql an explicit \q to shut it down, else pty won't close properly
$h->quit or die "psql returned $?";
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index b3bfe050b18..2ce007bced5 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -489,6 +489,15 @@ static const SchemaQuery Query_for_constraint_of_table_not_validated = {
.refnamespace = "c1.relnamespace",
};
+static const SchemaQuery Query_for_check_constraint_of_table = {
+ .catname = "pg_catalog.pg_constraint con, pg_catalog.pg_class c1",
+ .selcondition = "con.conrelid=c1.oid and con.contype='c'",
+ .result = "con.conname",
+ .refname = "c1.relname",
+ .refviscondition = "pg_catalog.pg_table_is_visible(c1.oid)",
+ .refnamespace = "c1.relnamespace",
+};
+
static const SchemaQuery Query_for_constraint_of_type = {
.catname = "pg_catalog.pg_constraint con, pg_catalog.pg_type t",
.selcondition = "con.contypid=t.oid",
@@ -2945,13 +2954,39 @@ match_previous_words(int pattern_id,
/* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED"))
- COMPLETE_WITH("ALWAYS", "BY DEFAULT");
- /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED */
+ COMPLETE_WITH("ALWAYS", "BY DEFAULT", "USING CONSTRAINT");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED ALWAYS */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS") ||
- Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
+ Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "ALWAYS"))
+ COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED BY DEFAULT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "BY", "DEFAULT"))
COMPLETE_WITH("AS IDENTITY");
+ /* ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev8_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT"))
+ {
+ set_completion_reference(prev7_wd);
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_check_constraint_of_table);
+ }
+
+ /*
+ * ALTER TABLE ALTER [COLUMN] <foo> ADD GENERATED USING CONSTRAINT
+ * constr_name
+ */
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
+ else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "ADD", "GENERATED", "USING", "CONSTRAINT", MatchAny))
+ COMPLETE_WITH("STORED");
/* ALTER TABLE ALTER [COLUMN] <foo> SET */
else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET") ||
Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET"))
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index aea7311faad..2daf732edb4 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2462,6 +2462,7 @@ typedef enum AlterTableType
AT_CookedColumnDefault, /* add a pre-cooked column default */
AT_DropNotNull, /* alter column drop not null */
AT_SetNotNull, /* alter column set not null */
+ AT_AddGeneratedStored, /* add generated using constraint */
AT_SetExpression, /* alter column set expression */
AT_DropExpression, /* alter column drop expression */
AT_SetStatistics, /* alter column set statistics */
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index 9d8b4b3540c..33f0fc758b2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -11,7 +11,7 @@ PGFILEDESC = "injection_points - facility for injection points"
EXTRA_INSTALL = contrib/test_decoding
-REGRESS = injection_points hashagg reindex_conc vacuum
+REGRESS = injection_points hashagg reindex_conc vacuum alter_table
REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
ISOLATION = basic \
diff --git a/src/test/modules/injection_points/expected/alter_table.out b/src/test/modules/injection_points/expected/alter_table.out
new file mode 100644
index 00000000000..215af90c57f
--- /dev/null
+++ b/src/test/modules/injection_points/expected/alter_table.out
@@ -0,0 +1,36 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+CREATE SCHEMA testgen_inj;
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+NOTICE: notice triggered for injection point alter-table-phase-3-verify
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 80a09f34d78..a180ee9de43 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -36,6 +36,7 @@ tests += {
'hashagg',
'reindex_conc',
'vacuum',
+ 'alter_table',
],
'regress_args': ['--dlpath', meson.project_build_root() / 'src/test/regress'],
# The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/injection_points/sql/alter_table.sql b/src/test/modules/injection_points/sql/alter_table.sql
new file mode 100644
index 00000000000..d9cf7952ed3
--- /dev/null
+++ b/src/test/modules/injection_points/sql/alter_table.sql
@@ -0,0 +1,24 @@
+-- Tests for ALTER TABLE
+CREATE EXTENSION injection_points;
+
+SELECT injection_points_set_local();
+
+SELECT injection_points_attach('alter-table-phase-3-rewrite', 'notice');
+SELECT injection_points_attach('alter-table-phase-3-verify', 'notice');
+
+CREATE SCHEMA testgen_inj;
+
+-- Check that the table isn't being rewritten nor scanned during phase 3,
+-- even if other objects depend on the column we are changing.
+CREATE TABLE testgen_inj.t1 (a INT, b INT NOT NULL);
+INSERT INTO testgen_inj.t1 (a, b) VALUES (1, 2);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c1 CHECK (b > 0);
+ALTER TABLE testgen_inj.t1 ADD CONSTRAINT c2 CHECK (b IS NOT DISTINCT FROM a * 2);
+CREATE INDEX ON testgen_inj.t1 (b);
+ALTER TABLE testgen_inj.t1 ALTER b
+ ADD GENERATED USING CONSTRAINT c2 STORED;
+-- we expect to *not* see a "alter-table-phase-3-*" notice here
+DROP TABLE testgen_inj.t1;
+DROP SCHEMA testgen_inj;
+
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 105872a3517..47b8337307d 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -129,6 +129,9 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
case AT_SetNotNull:
strtype = "SET NOT NULL";
break;
+ case AT_AddGeneratedStored:
+ strtype = "ADD GENERATED STORED";
+ break;
case AT_SetExpression:
strtype = "SET EXPRESSION";
break;
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..899a0baf573 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4876,3 +4876,413 @@ drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
NOTICE: drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+ Table "tgen.t1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT (a * 2) IS DISTINCT FROM b)
+
+insert into tgen.t1 (a, b) values (10, 20);
+ERROR: cannot insert a non-DEFAULT value into column "b"
+DETAIL: Column "b" is a generated column.
+rollback;
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+ did_skip_table_rewrite | did_skip_idx_rewrite
+------------------------+----------------------
+ t | t
+(1 row)
+
+rollback;
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+--------------------------
+ relation | ShareUpdateExclusiveLock
+(1 row)
+
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+ locktype | mode
+----------+---------------------
+ relation | AccessExclusiveLock
+(1 row)
+
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+ did_skip_rewrite
+------------------
+ t
+(1 row)
+
+select * from tgen.t_migration;
+ a | b
+-----+-----
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ |
+(9 rows)
+
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+ Table "tgen.t_migration"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Triggers:
+ tgen_trig BEFORE INSERT OR UPDATE ON tgen.t_migration FOR EACH ROW EXECUTE FUNCTION tgen.gen()
+
+select * from tgen.t_migration order by a nulls first;
+ a | b
+-----+------
+ |
+ 1 | 2
+ 2 | 4
+ 3 | 6
+ 4 | 8
+ 5 | 10
+ 100 | 200
+ 200 | 400
+ 300 | 600
+ 400 | 800
+ 500 | 1000
+(11 rows)
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+ Partitioned table "tgen.tpart"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition key: HASH (a)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+Number of partitions: 2 (Use \d+ to list them.)
+
+\d tgen.tpart_p1
+ Table "tgen.tpart_p1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 0)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+\d tgen.tpart_p2
+ Table "tgen.tpart_p2"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------
+ a | integer | | |
+ b | integer | | | generated always as (a * 2) stored
+Partition of: tgen.tpart FOR VALUES WITH (modulus 2, remainder 1)
+Check constraints:
+ "chk_gen" CHECK (NOT b IS DISTINCT FROM (a * 2))
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Could not find CHECK constraint "chk_gen".
+rollback;
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Converting only part of a partitioning or inheritance hierarchy is not supported.
+HINT: Use this command on the root table without specifying ONLY.
+drop table tgen.root cascade;
+NOTICE: drop cascades to 2 other objects
+DETAIL: drop cascades to table tgen.intermediate
+drop cascades to table tgen.leaf
+-- tests for invalid invocations
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+ERROR: syntax error at or near ";"
+LINE 1: ...en.t1 alter column b add generated using constraint chk_gen;
+ ^
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+ERROR: syntax error at or near "virtual"
+LINE 1: ...ter column b add generated using constraint chk_gen virtual;
+ ^
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t1" is already a generated column.
+rollback;
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is already a generated column.
+drop table tgen.t2;
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is an identity column.
+drop table tgen.t2;
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" of relation "t2" is a serial column (depends on "sequence tgen.t2_b_seq").
+drop table tgen.t2;
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot convert column "c" to generated
+DETAIL: Column "c" is referenced by generated column "b".
+drop table tgen.t2;
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+ERROR: cannot use generated column "b" in column generation expression
+DETAIL: A generated column cannot reference another generated column.
+drop table tgen.t2;
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t2".
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two tables are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_1".
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Column "b" is referenced in the partition key of relation "t_wholerow_2".
+set search_path to :search_path;
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: Could not find CHECK constraint "chk_gen_does_not_exist".
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not valid.
+rollback;
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+rollback;
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+ERROR: cannot convert column "b" to generated
+DETAIL: The constraint "chk_gen" is not of the form CHECK ("b" IS NOT DISTINCT FROM expr).
+HINT: Ensure that the type of the expression matches the type of the column.
+rollback;
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+ERROR: generation expression is not immutable
+drop table tgen.t2;
+drop table tgen.t1;
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..966f1e00bf9 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3161,3 +3161,267 @@ alter table alter1.t1 set schema alter2;
drop publication pub1;
drop schema alter1 cascade;
drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED USING CONSTRAINT name STORED
+-- turning a regular column into a stored generated column without a rewrite
+create schema tgen;
+
+create table tgen.t1 (a int, b int);
+insert into tgen.t1 (a, b)
+ select x, x * 2 from generate_series(1, 5) x;
+
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- test that we accept IS NOT DISTINCT FROM with the operands swapped, too
+begin;
+alter table tgen.t1 add constraint chk_gen check (a * 2 is not distinct from b);
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+\d tgen.t1
+insert into tgen.t1 (a, b) values (10, 20);
+rollback;
+
+-- check that neither the table nor indexes are rewritten
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2);
+create index t1_a_idx on tgen.t1 (a);
+select pg_relation_filenode('tgen.t1') as t1_filenode_before \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_before \gset
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+select pg_relation_filenode('tgen.t1') as t1_filenode_after \gset
+select pg_relation_filenode('tgen.t1_a_idx') as t1_idx_filenode_after \gset
+select :t1_filenode_before = :t1_filenode_after as did_skip_table_rewrite,
+ :t1_idx_filenode_before = :t1_idx_filenode_after as did_skip_idx_rewrite;
+rollback;
+
+-- test the whole process for adding a stored generated column without
+-- long-lived exclusive locks. The t_migration database is intentionally
+-- left behind for pg_dump/pg_restore tests.
+create table tgen.t_migration (a int);
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_before \gset
+insert into tgen.t_migration select x from generate_series(1, 5) x;
+-- test nulls, too
+insert into tgen.t_migration (a) values (null);
+alter table tgen.t_migration add column b int;
+-- take care of new and updated columns
+create function tgen.gen () returns trigger language plpgsql as $$
+begin
+ new.b = new.a * 2; return new;
+end
+$$;
+create trigger tgen_trig
+ before insert or update on tgen.t_migration
+ for each row execute function tgen.gen();
+-- add the constraint as not valid: enforced only for new and updated rows
+begin;
+alter table tgen.t_migration
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+insert into tgen.t_migration (a) values (100), (200), (300);
+-- backfill existing rows at the appropriate pace
+update tgen.t_migration set b = a * 2 where b is null;
+-- validate: this scans the table, but without an exclusive lock
+begin;
+alter table tgen.t_migration validate constraint chk_gen;
+select locktype, mode from pg_locks
+ where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+-- now the schema update, which doesn't need to rewrite the table thanks to
+-- the constraint
+begin;
+alter table tgen.t_migration alter column b
+ add generated using constraint chk_gen stored;
+select locktype, mode from pg_locks
+where relation = 'tgen.t_migration'::regclass and granted;
+commit;
+select pg_relation_filenode('tgen.t_migration') as t_migration_filenode_after \gset
+select :t_migration_filenode_before = :t_migration_filenode_after as did_skip_rewrite;
+select * from tgen.t_migration;
+-- verify that it's still possible to insert rows (the trigger is still
+-- installed at this point)
+insert into tgen.t_migration (a) values (400);
+insert into tgen.t_migration (a) values (500);
+\d tgen.t_migration
+select * from tgen.t_migration order by a nulls first;
+
+-- test support for partitioned tables
+create table tgen.tpart (a int, b int) partition by hash (a);
+alter table tgen.tpart
+ add constraint chk_gen check (b is not distinct from a * 2);
+create table tgen.tpart_p1 partition of tgen.tpart
+ for values with (modulus 2, remainder 0);
+create table tgen.tpart_p2 partition of tgen.tpart
+ for values with (modulus 2, remainder 1);
+insert into tgen.tpart (a, b) select x, x * 2 from generate_series(1, 5) x;
+
+-- altering a single partition is not allowed
+alter table tgen.tpart_p1 alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering only the parent table is not allowed
+alter table only tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+
+-- altering the parent table, recursing
+alter table tgen.tpart alter column b
+ add generated using constraint chk_gen stored;
+-- expected: all the partitions have been changed
+\d tgen.tpart
+\d tgen.tpart_p1
+\d tgen.tpart_p2
+
+-- tpart intentionally left behind for pg_dump/pg_restore tests
+
+-- test support for inheritance and subpartitions
+create table tgen.root (a int, b int, c int);
+create table tgen.intermediate () inherits (tgen.root);
+create table tgen.leaf () inherits (tgen.intermediate);
+
+-- it's only allowed to change the whole hierarchy at once...
+begin;
+alter table tgen.root alter column c
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- ... hence all these should result in an error
+alter table only tgen.root alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.intermediate alter column c
+ add generated using constraint chk_gen stored;
+alter table tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+alter table only tgen.leaf alter column c
+ add generated using constraint chk_gen stored;
+
+drop table tgen.root cascade;
+
+-- tests for invalid invocations
+-- invalid: only supports STORED. The following are syntax errors.
+alter table tgen.t1 alter column b add generated using constraint chk_gen;
+alter table tgen.t1 alter column b add generated using constraint chk_gen virtual;
+
+-- invalid: b is already a generated column
+begin;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+alter table tgen.t1 alter b add generated using constraint chk_gen stored;
+rollback;
+
+-- invalid: b is already a generated column
+create table tgen.t2 (a int, b int not null generated always as (a * 2) stored);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a * 2);
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is an identity column
+create table tgen.t2 (a int, b int generated always as identity);
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+create table tgen.t2 (a int, b int generated by default as identity );
+alter table tgen.t2 alter column b add generated using constraint doesnotexist stored;
+drop table tgen.t2;
+
+-- invalid: b is a serial column
+create table tgen.t2 (a int, b bigserial);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (1));
+alter table tgen.t2 alter column b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c is referenced by another generated column
+create table tgen.t2 (a int, b int generated always as (c + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: c references another generated column
+create table tgen.t2 (a int, b int generated always as (a + 1), c int);
+alter table tgen.t2 add constraint chk_gen check (c is not distinct from (b + 1));
+alter table tgen.t2 alter column c add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+-- invalid: b is referenced in a partition key
+create table tgen.t2 (a int, b int not null) partition by hash (b);
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t2 alter b add generated using constraint chk_gen stored;
+drop table tgen.t2;
+-- test for a whole-row reference
+-- since it's not possible to reference schema.table in partition by range,
+-- temporarily hack the search_path
+-- the two tables are intentionally left behind for pg_dump/pg_restore tests
+show search_path \gset
+set search_path to tgen, public;
+create table t_wholerow_1 (a int, b int) partition by range ((t_wholerow_1));
+alter table tgen.t_wholerow_1 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_1 alter b add generated using constraint chk_gen stored;
+create table t_wholerow_2 (a int, b int) partition by range ((t_wholerow_2 is null));
+alter table tgen.t_wholerow_2 add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_wholerow_2 alter b add generated using constraint chk_gen stored;
+set search_path to :search_path;
+
+-- fails when the constraint does not exist
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen_does_not_exist stored;
+
+-- fails when the constraint is not valid
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not valid;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint is not enforced
+begin;
+alter table tgen.t1
+ add constraint chk_gen check (b is not distinct from a * 2) not enforced;
+alter table tgen.t1 alter column b
+ add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the constraint exists but doesn't have the expected shape
+begin;
+alter table tgen.t1 add constraint chk_gen check (b = a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+begin;
+alter table tgen.t1 add constraint chk_gen check (b >= a * 2);
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+-- fails when the expression does not match the type of the column and is
+-- implicitly cast
+begin;
+truncate tgen.t1;
+alter table tgen.t1 add constraint chk_gen check (b is not distinct from (a + random()));
+-- the hint should inform about the type cast
+alter table tgen.t1 alter column b add generated using constraint chk_gen stored;
+rollback;
+
+create table tgen.t2 (a int, b int);
+-- invalid: expr must be immutable
+-- (without the cast to int, the expression would return a float and wouldn't
+-- match the type of the column b)
+alter table tgen.t2 add constraint chk_gen check (b is not distinct from (a + random()::int));
+alter table tgen.t2 alter column b
+ add generated using constraint chk_gen stored;
+drop table tgen.t2;
+
+drop table tgen.t1;
+
+-- left behind for pg_dump/pg_restore tests
+create table tgen.t_simple (a int, b int not null);
+insert into tgen.t_simple (a, b) values (1, 2), (2, 3);
+alter table tgen.t_simple add constraint chk_gen check (b is not distinct from a + 1);
+alter table tgen.t_simple alter b add generated using constraint chk_gen stored;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 0dc817cc2b8..f98a458d5d2 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -27,6 +27,7 @@ AcquireSampleRowsFunc
ActionList
ActiveSnapshotElt
AddForeignUpdateTargets_function
+AddGenConstrError
AddrInfo
AffixNode
AffixNodeData
base-commit: fcc0e27f45e2d8dc6046435908a48626c91d3d84
--
2.47.0
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-21 21:48 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-22 13:02 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-22 15:17 ` Re: Adding a stored generated column without long-lived locks Álvaro Herrera <alvherre@kurilemu.de>
0 siblings, 2 replies; 37+ messages in thread
From: Alberto Piai @ 2026-09-21 21:48 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; Laurenz Albe <laurenz.albe@cybertec.at>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
Hi all,
in an attempt to avoid wasting committer time, I decided to use an
LLM-based tool to analyze this patch and try to come up with
counterexamples to break my usage of IS NOT DISTINCT FROM.
It produced an example showing how IS NOT DISTINCT FROM isn't good
enough either for my purpose.
The problem is types where some values are evaluated as equal (according
to =), but don't have the same representation. In conjuction with a
unique index, they could be used to put a database in an invalid state
where rewriting operations (update ... set a = a or pg_dump/pg_restore)
fail.
Repro:
create table tgen.t_repro_1 (a numeric, b numeric);
insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
create unique index on tgen.t_repro_1 ((b::text));
alter table tgen.t_repro_1
add constraint chk_gen check (b is not distinct from a);
alter table tgen.t_repro_1
alter b add generated using constraint chk_gen stored;
update tgen.t_repro_1 set a = a;
ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
DETAIL: Key ((b::text))=(1.0) already exists.
I will have to re-think this quite a bit. In general it's clear that I
need to use an operator which proves that the value is identical bit by
bit to what evaluating the expression would produce. The question will
be how to make this accessible and usable enough, and at which cost in
complexity.
I won't have time to work on this for at least a few days, so in the
meantime I will remove the "ready for committer" tag to avoid luring
anyone into looking at this (at least with the committer hat on). Any
feedback | thought | idea would of course be very welcome! :)
Kind regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-22 13:02 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-22 13:37 ` Re: Adding a stored generated column without long-lived locks Matthias van de Meent <boekewurm+postgres@gmail.com>
1 sibling, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-09-22 13:02 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers; +Cc: Álvaro Herrera <alvherre@kurilemu.de>
On Mon, 2026-09-21 at 23:48 +0200, Alberto Piai wrote:
> in an attempt to avoid wasting committer time, I decided to use an
> LLM-based tool to analyze this patch and try to come up with
> counterexamples to break my usage of IS NOT DISTINCT FROM.
>
> It produced an example showing how IS NOT DISTINCT FROM isn't good
> enough either for my purpose.
>
> The problem is types where some values are evaluated as equal (according
> to =), but don't have the same representation. In conjuction with a
> unique index, they could be used to put a database in an invalid state
> where rewriting operations (update ... set a = a or pg_dump/pg_restore)
> fail.
>
> Repro:
>
> create table tgen.t_repro_1 (a numeric, b numeric);
> insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
> create unique index on tgen.t_repro_1 ((b::text));
> alter table tgen.t_repro_1
> add constraint chk_gen check (b is not distinct from a);
>
> alter table tgen.t_repro_1
> alter b add generated using constraint chk_gen stored;
>
> update tgen.t_repro_1 set a = a;
> ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
> DETAIL: Key ((b::text))=(1.0) already exists.
>
>
> I will have to re-think this quite a bit.
Ho, hum. Case insensitive collations would be another example.
There is no way to write "is binary identical to" in SQL, as far as
I can tell.
Perhaps a solution would be to force the use of the data type's send
function in the check constraint:
ALTER TABLE t_repro_1 ADD CHECK
(numeric_send(b) IS NOT DISTINCT FROM numeric_send(a));
That would exclude data types that don't have a send function
(which is probably no big loss), and I am not certain if the send
function is required to represent the binary data exactly (it does
for the system data types, as far as I know).
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-22 13:02 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-09-22 13:37 ` Matthias van de Meent <boekewurm+postgres@gmail.com>
0 siblings, 0 replies; 37+ messages in thread
From: Matthias van de Meent @ 2026-09-22 13:37 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; +Cc: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers@postgresql.org, Álvaro Herrera <alvherre@kurilemu.de>
On Tue, 22 Sept 2026 at 15:02, Laurenz Albe <laurenz.albe@cybertec.at> wrote:
>
> On Mon, 2026-09-21 at 23:48 +0200, Alberto Piai wrote:
> > in an attempt to avoid wasting committer time, I decided to use an
> > LLM-based tool to analyze this patch and try to come up with
> > counterexamples to break my usage of IS NOT DISTINCT FROM.
> >
> > It produced an example showing how IS NOT DISTINCT FROM isn't good
> > enough either for my purpose.
> >
> > The problem is types where some values are evaluated as equal (according
> > to =), but don't have the same representation. In conjuction with a
> > unique index, they could be used to put a database in an invalid state
> > where rewriting operations (update ... set a = a or pg_dump/pg_restore)
> > fail.
> >
> > Repro:
> >
> > create table tgen.t_repro_1 (a numeric, b numeric);
> > insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
> > create unique index on tgen.t_repro_1 ((b::text));
> > alter table tgen.t_repro_1
> > add constraint chk_gen check (b is not distinct from a);
> >
> > alter table tgen.t_repro_1
> > alter b add generated using constraint chk_gen stored;
> >
> > update tgen.t_repro_1 set a = a;
> > ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
> > DETAIL: Key ((b::text))=(1.0) already exists.
> >
> >
> > I will have to re-think this quite a bit.
>
> Ho, hum. Case insensitive collations would be another example.
>
> There is no way to write "is binary identical to" in SQL, as far as
> I can tell.
None yet: [0] is a patch that adds a pg_datum_image_equal(a, b)
function that should allow users to detect binary differences in all
types of values. It won't detect differences in the way data is
stored (it only checks detoasted datum differences, so toast ID or
compression types are not tested), but I think the function added in
that patch is sufficient for this use case, too.
> Perhaps a solution would be to force the use of the data type's send
> function in the check constraint:
Going through sendfn can be rather expensive (even compared to
detoasting), which is why I started the work on [0]: At databricks we
had a comparable use case of needing to detect exactly equal input
data, as cheap as possible.
Note that "is not distinct from" will work correctly when the
underlying = operator is part of a btree opfamily whose equalimage()
support function returns true; this is something that you can use to
make sure the selected constraint in ADD GENERATED can safely be used
even if some other types won't work. The equalimage() support
function's result indicates whether the type has a strict total
ordering (when true), or whether there are values which have different
representations but are considered equal in this opclass' sort
ordering (when false). This same support function also indicates
whether deduplication can be used for the indexed data (when true) or
not (when false).
Kind regards,
Matthias van de Meent
Databricks (https://www.databricks.com)
[0] https://commitfest.postgresql.org/patch/6309/
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-22 15:17 ` Álvaro Herrera <alvherre@kurilemu.de>
2026-09-23 10:07 ` Re: Adding a stored generated column without long-lived locks Matthias van de Meent <boekewurm+postgres@gmail.com>
1 sibling, 1 reply; 37+ messages in thread
From: Álvaro Herrera @ 2026-09-22 15:17 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; +Cc: Laurenz Albe <laurenz.albe@cybertec.at>; pgsql-hackers
On 2026-Sep-21, Alberto Piai wrote:
> Repro:
>
> create table tgen.t_repro_1 (a numeric, b numeric);
> insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
> create unique index on tgen.t_repro_1 ((b::text));
> alter table tgen.t_repro_1
> add constraint chk_gen check (b is not distinct from a);
>
> alter table tgen.t_repro_1
> alter b add generated using constraint chk_gen stored;
>
> update tgen.t_repro_1 set a = a;
> ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
> DETAIL: Key ((b::text))=(1.0) already exists.
Does this _matter_?
Does anybody want to have a generated numeric column that's identical to
the base column except it has more zeroes in the decimal part? Or to
generate a text column that's not binary identical to another text
column but compares equal when viewed through an nondeterministic
collation?
I think the answer is no.
I'm pretty okay with saying that if you want to add a new column that's
generated in such a way, then you have to go the normal route of using
the blocking command.
--
Álvaro Herrera Breisgau, Deutschland — https://www.EnterpriseDB.com/
"La experiencia nos dice que el hombre peló millones de veces las patatas,
pero era forzoso admitir la posibilidad de que en un caso entre millones,
las patatas pelarían al hombre" (Ijon Tichy)
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-22 15:17 ` Re: Adding a stored generated column without long-lived locks Álvaro Herrera <alvherre@kurilemu.de>
@ 2026-09-23 10:07 ` Matthias van de Meent <boekewurm+postgres@gmail.com>
2026-09-23 10:26 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Matthias van de Meent @ 2026-09-23 10:07 UTC (permalink / raw)
To: Álvaro Herrera <alvherre@kurilemu.de>; +Cc: Alberto Piai <alberto.piai@gmail.com>; Laurenz Albe <laurenz.albe@cybertec.at>; pgsql-hackers
On Tue, 22 Sept 2026 at 17:17, Álvaro Herrera <alvherre@kurilemu.de> wrote:
>
> On 2026-Sep-21, Alberto Piai wrote:
>
> > Repro:
> >
> > create table tgen.t_repro_1 (a numeric, b numeric);
> > insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
> > create unique index on tgen.t_repro_1 ((b::text));
> > alter table tgen.t_repro_1
> > add constraint chk_gen check (b is not distinct from a);
> >
> > alter table tgen.t_repro_1
> > alter b add generated using constraint chk_gen stored;
> >
> > update tgen.t_repro_1 set a = a;
> > ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
> > DETAIL: Key ((b::text))=(1.0) already exists.
>
> Does this _matter_?
Yes, this does matter. We can't start ignoring and tossing
significant zeroes in numeric types, because they have meaning in
scientific calculations, and have different outputs for various
operations, including casts to text, but also functions like scale.
Generated columns should (must) respect that, and produce the same
result between SELECT (gen_col_expr) and SELECT gen_col.
> Does anybody want to have a generated numeric column that's identical to
> the base column except it has more zeroes in the decimal part? Or to
> generate a text column that's not binary identical to another text
> column but compares equal when viewed through an nondeterministic
> collation?
I don't think anyone wants that specifically, but if the database
state contains that numeric column that's identical to the first, but
with more zeroes, or a text column with different encoding, then that
must be detected before we go ahead and fully consider the target
column to be a generated version of the source column; the outputs
must be identical even if the default = operator thinks they have the
same value.
Note that this issue extends to at least jsonb, which uses textual and
numeric comparators internally for the sorting operations; it's
certainly possible to remove or add a few zeroes here and there in
projection steps if you're not careful when you populate the
not-yet-GENERATED column's data.
Kind regards,
Matthias van de Meent
Databricks (https://www.databricks.com)
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-22 15:17 ` Re: Adding a stored generated column without long-lived locks Álvaro Herrera <alvherre@kurilemu.de>
2026-09-23 10:07 ` Re: Adding a stored generated column without long-lived locks Matthias van de Meent <boekewurm+postgres@gmail.com>
@ 2026-09-23 10:26 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-23 22:19 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
0 siblings, 1 reply; 37+ messages in thread
From: Laurenz Albe @ 2026-09-23 10:26 UTC (permalink / raw)
To: Matthias van de Meent <boekewurm+postgres@gmail.com>; Álvaro Herrera <alvherre@kurilemu.de>; +Cc: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Wed, 2026-09-23 at 12:07 +0200, Matthias van de Meent wrote:
> On Tue, 22 Sept 2026 at 17:17, Álvaro Herrera <alvherre@kurilemu.de> wrote:
> > On 2026-Sep-21, Alberto Piai wrote:
> >
> > > Repro:
> > >
> > > create table tgen.t_repro_1 (a numeric, b numeric);
> > > insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
> > > create unique index on tgen.t_repro_1 ((b::text));
> > > alter table tgen.t_repro_1
> > > add constraint chk_gen check (b is not distinct from a);
> > >
> > > alter table tgen.t_repro_1
> > > alter b add generated using constraint chk_gen stored;
> > >
> > > update tgen.t_repro_1 set a = a;
> > > ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
> > > DETAIL: Key ((b::text))=(1.0) already exists.
> >
> > Does this _matter_?
>
> Yes, this does matter.
I tend to agree.
Rather than just documenting this problem, I'd say that your idea with
the equalimage() support function has appeal. We can simply reject the
operation if the function returns FALSE.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-22 15:17 ` Re: Adding a stored generated column without long-lived locks Álvaro Herrera <alvherre@kurilemu.de>
2026-09-23 10:07 ` Re: Adding a stored generated column without long-lived locks Matthias van de Meent <boekewurm+postgres@gmail.com>
2026-09-23 10:26 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
@ 2026-09-23 22:19 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-24 10:47 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 1 reply; 37+ messages in thread
From: Alberto Piai @ 2026-09-23 22:19 UTC (permalink / raw)
To: Laurenz Albe <laurenz.albe@cybertec.at>; Matthias van de Meent <boekewurm+postgres@gmail.com>; Álvaro Herrera <alvherre@kurilemu.de>; +Cc: Alberto Piai <alberto.piai@gmail.com>; pgsql-hackers
On Wed Sep 23, 2026 at 12:26 PM CEST, Laurenz Albe wrote:
> On Wed, 2026-09-23 at 12:07 +0200, Matthias van de Meent wrote:
>> On Tue, 22 Sept 2026 at 17:17, Álvaro Herrera <alvherre@kurilemu.de> wrote:
>> > On 2026-Sep-21, Alberto Piai wrote:
>> >
>> > > Repro:
>> > >
>> > > create table tgen.t_repro_1 (a numeric, b numeric);
>> > > insert into tgen.t_repro_1 values ('1.0', '1.00'), ('1.0', '1.0');
>> > > create unique index on tgen.t_repro_1 ((b::text));
>> > > alter table tgen.t_repro_1
>> > > add constraint chk_gen check (b is not distinct from a);
>> > >
>> > > alter table tgen.t_repro_1
>> > > alter b add generated using constraint chk_gen stored;
>> > >
>> > > update tgen.t_repro_1 set a = a;
>> > > ERROR: duplicate key value violates unique constraint "t_repro_1_b_idx"
>> > > DETAIL: Key ((b::text))=(1.0) already exists.
>> >
>> > Does this _matter_?
>>
>> Yes, this does matter.
>
> I tend to agree.
I too think this matters. The main arugment is IMHO the catastrophic
failure mode: successful ALTER TABLE command, broken pg_restore... I
would hate to put anyone in that situation.
I find Matthias' proposal of exposing a function to check image equality
very compelling for the purpose of this patch: besides fixing this
problem, it would also make the command usable for data types which
don't define = (json), as well as those which don't (can't?) define
equalimage()... jsonb, numeric but also tsvector and PostGIS geometry.
I'll take some time to study/review the patch and the discussion around
it.
Kind regards,
Alberto
--
Alberto Piai
Sensational AG
Zürich, Switzerland
^ permalink raw reply [nested|flat] 37+ messages in thread
* Re: Adding a stored generated column without long-lived locks
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-05-27 17:43 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-09-22 15:17 ` Re: Adding a stored generated column without long-lived locks Álvaro Herrera <alvherre@kurilemu.de>
2026-09-23 10:07 ` Re: Adding a stored generated column without long-lived locks Matthias van de Meent <boekewurm+postgres@gmail.com>
2026-09-23 10:26 ` Re: Adding a stored generated column without long-lived locks Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-23 22:19 ` Re: Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
@ 2026-09-24 10:47 ` Laurenz Albe <laurenz.albe@cybertec.at>
0 siblings, 0 replies; 37+ messages in thread
From: Laurenz Albe @ 2026-09-24 10:47 UTC (permalink / raw)
To: Alberto Piai <alberto.piai@gmail.com>; Matthias van de Meent <boekewurm+postgres@gmail.com>; Álvaro Herrera <alvherre@kurilemu.de>; +Cc: pgsql-hackers
On Thu, 2026-09-24 at 00:19 +0200, Alberto Piai wrote:
> I find Matthias' proposal of exposing a function to check image equality
> very compelling for the purpose of this patch: besides fixing this
> problem, it would also make the command usable for data types which
> don't define = (json), as well as those which don't (can't?) define
> equalimage()... jsonb, numeric but also tsvector and PostGIS geometry.
True, "tsvector" is limiting; I can see people wanting that for
generated columns.
Yours,
Laurenz Albe
^ permalink raw reply [nested|flat] 37+ messages in thread
end of thread, other threads:[~2026-09-24 10:47 UTC | newest]
Thread overview: 37+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-03-17 10:31 Adding a stored generated column without long-lived locks Alberto Piai <alberto.piai@gmail.com>
2026-04-07 09:09 ` Alberto Piai <alberto.piai@gmail.com>
2026-04-24 09:10 ` Alberto Piai <alberto.piai@gmail.com>
2026-05-14 22:46 ` Alberto Piai <alberto.piai@gmail.com>
2026-05-26 15:23 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-05-27 17:44 ` Alberto Piai <alberto.piai@gmail.com>
2026-06-15 20:41 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-05-27 17:43 ` Alberto Piai <alberto.piai@gmail.com>
2026-06-15 18:38 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-06-30 13:44 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-03 06:42 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-07 18:16 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-07-10 00:10 ` Alberto Piai <alberto.piai@gmail.com>
2026-07-10 06:51 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-06 12:36 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-24 17:29 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-24 20:03 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-25 08:46 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-25 16:13 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-26 05:20 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 05:50 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-28 13:36 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-28 16:07 ` Alberto Piai <alberto.piai@gmail.com>
2026-08-29 06:14 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-08-31 18:41 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-01 04:58 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-01 09:02 ` solai v <solai.cdac@gmail.com>
2026-09-01 15:41 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-16 17:02 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-21 21:48 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-22 13:02 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-22 13:37 ` Matthias van de Meent <boekewurm+postgres@gmail.com>
2026-09-22 15:17 ` Álvaro Herrera <alvherre@kurilemu.de>
2026-09-23 10:07 ` Matthias van de Meent <boekewurm+postgres@gmail.com>
2026-09-23 10:26 ` Laurenz Albe <laurenz.albe@cybertec.at>
2026-09-23 22:19 ` Alberto Piai <alberto.piai@gmail.com>
2026-09-24 10:47 ` Laurenz Albe <laurenz.albe@cybertec.at>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox