agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v32 06/11] Add Incremental View Maintenance support
162+ messages / 2 participants
[nested] [flat]

* [PATCH v32 06/11] Add Incremental View Maintenance support
@ 2023-05-31 09:59  Yugo Nagata <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Yugo Nagata @ 2023-05-31 09:59 UTC (permalink / raw)

In this implementation, AFTER triggers are used to collect
tuplestores containing transition table contents. When multiple tables
are changed, multiple AFTER triggers are invoked, then the final AFTER
trigger performs actual update of the matview. In addition, BEFORE
triggers are also used to handle global information for view
maintenance.

To calculate view deltas, we need both pre-state and post-state of base
tables. Post-update states are available in AFTER trigger, and pre-update
states can be calculated by removing inserted tuples and appending deleted
tuples. Insterted tuples are filtered using the snapshot taken before
table modiication, and deleted tuples are contained in the old transition
table.

Incrementally Maintainable Materialized Views (IMMV) can contain
duplicated tuples.

This patch also allows self-join, simultaneous updates of more than
one base table, and multiple updates of the same base table.
---
 src/backend/access/transam/xact.c |    5 +
 src/backend/commands/createas.c   |  682 ++++++++++++++
 src/backend/commands/matview.c    | 1468 ++++++++++++++++++++++++++++-
 src/include/catalog/pg_proc.dat   |   10 +
 src/include/commands/createas.h   |    4 +
 src/include/commands/matview.h    |    9 +
 6 files changed, 2143 insertions(+), 35 deletions(-)

diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index df5a67e4c3..a2d5404912 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -36,6 +36,7 @@
 #include "catalog/pg_enum.h"
 #include "catalog/storage.h"
 #include "commands/async.h"
+#include "commands/matview.h"
 #include "commands/tablecmds.h"
 #include "commands/trigger.h"
 #include "common/pg_prng.h"
@@ -2860,6 +2861,7 @@ AbortTransaction(void)
 	AtAbort_Notify();
 	AtEOXact_RelationMap(false, is_parallel_worker);
 	AtAbort_Twophase();
+	AtAbort_IVM();
 
 	/*
 	 * Advertise the fact that we aborted in pg_xact (assuming that we got as
@@ -5198,6 +5200,9 @@ AbortSubTransaction(void)
 	pgstat_progress_end_command();
 	UnlockBuffers();
 
+	/* Clean up hash entries for incremental view maintenance */
+	AtAbort_IVM();
+
 	/* Reset WAL record construction state */
 	XLogResetInsertion();
 
diff --git a/src/backend/commands/createas.c b/src/backend/commands/createas.c
index afd3dace07..e9846c8d0f 100644
--- a/src/backend/commands/createas.c
+++ b/src/backend/commands/createas.c
@@ -29,15 +29,27 @@
 #include "access/tableam.h"
 #include "access/xact.h"
 #include "catalog/namespace.h"
+#include "catalog/index.h"
+#include "catalog/pg_constraint.h"
+#include "catalog/pg_inherits.h"
+#include "catalog/pg_trigger.h"
 #include "catalog/toasting.h"
 #include "commands/createas.h"
+#include "commands/defrem.h"
 #include "commands/matview.h"
 #include "commands/prepare.h"
 #include "commands/tablecmds.h"
+#include "commands/tablespace.h"
+#include "commands/trigger.h"
 #include "commands/view.h"
 #include "miscadmin.h"
+#include "optimizer/optimizer.h"
+#include "optimizer/prep.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "parser/parser.h"
+#include "parser/parsetree.h"
+#include "parser/parse_clause.h"
 #include "rewrite/rewriteHandler.h"
 #include "tcop/tcopprot.h"
 #include "utils/builtins.h"
@@ -68,6 +80,12 @@ static bool intorel_receive(TupleTableSlot *slot, DestReceiver *self);
 static void intorel_shutdown(DestReceiver *self);
 static void intorel_destroy(DestReceiver *self);
 
+static void CreateIvmTriggersOnBaseTablesRecurse(Query *qry, Node *node, Oid matviewOid,
+									 Relids *relids, bool ex_lock);
+static void CreateIvmTrigger(Oid relOid, Oid viewOid, int16 type, int16 timing, bool ex_lock);
+static void check_ivm_restriction(Node *node);
+static bool check_ivm_restriction_walker(Node *node, void *context);
+static Bitmapset *get_primary_key_attnos_from_query(Query *query, List **constraintList);
 
 /*
  * create_ctas_internal
@@ -277,6 +295,18 @@ ExecCreateTableAs(ParseState *pstate, CreateTableAsStmt *stmt,
 		save_nestlevel = NewGUCNestLevel();
 	}
 
+	if (is_matview && into->ivm)
+	{
+		/* check if the query is supported in IMMV definition */
+		if (contain_mutable_functions((Node *) query))
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("mutable function is not supported on incrementally maintainable materialized view"),
+					 errhint("functions must be marked IMMUTABLE")));
+
+		check_ivm_restriction((Node *) query);
+	}
+
 	if (into->skipData)
 	{
 		/*
@@ -353,6 +383,27 @@ ExecCreateTableAs(ParseState *pstate, CreateTableAsStmt *stmt,
 
 		/* Restore userid and security context */
 		SetUserIdAndSecContext(save_userid, save_sec_context);
+
+		if (into->ivm)
+		{
+			Oid matviewOid = address.objectId;
+			Relation matviewRel = table_open(matviewOid, NoLock);
+
+			/*
+			 * Mark relisivm field, if it's a matview and into->ivm is true.
+			 */
+			SetMatViewIVMState(matviewRel, true);
+
+			if (!into->skipData)
+			{
+				/* Create an index on incremental maintainable materialized view, if possible */
+				CreateIndexOnIMMV((Query *) into->viewQuery, matviewRel);
+
+				/* Create triggers on incremental maintainable materialized view */
+				CreateIvmTriggersOnBaseTables((Query *) into->viewQuery, matviewOid);
+			}
+			table_close(matviewRel, NoLock);
+		}
 	}
 
 	return address;
@@ -632,3 +683,634 @@ intorel_destroy(DestReceiver *self)
 {
 	pfree(self);
 }
+
+/*
+ * CreateIvmTriggersOnBaseTables -- create IVM triggers on all base tables
+ */
+void
+CreateIvmTriggersOnBaseTables(Query *qry, Oid matviewOid)
+{
+	Relids	relids = NULL;
+	bool	ex_lock = false;
+	RangeTblEntry *rte;
+
+	/* Immediately return if we don't have any base tables. */
+	if (list_length(qry->rtable) < 1)
+		return;
+
+	/*
+	 * If the view has more than one base tables, we need an exclusive lock
+	 * on the view so that the view would be maintained serially to avoid
+	 * the inconsistency that occurs when two base tables are modified in
+	 * concurrent transactions. However, if the view has only one table,
+	 * we can use a weaker lock.
+	 *
+	 * The type of lock should be determined here, because if we check the
+	 * view definition at maintenance time, we need to acquire a weaker lock,
+	 * and upgrading the lock level after this increases probability of
+	 * deadlock.
+	 */
+
+	rte = list_nth(qry->rtable, 0);
+	if (list_length(qry->rtable) > 1 || rte->rtekind != RTE_RELATION)
+		ex_lock = true;
+
+	CreateIvmTriggersOnBaseTablesRecurse(qry, (Node *)qry, matviewOid, &relids, ex_lock);
+
+	bms_free(relids);
+}
+
+static void
+CreateIvmTriggersOnBaseTablesRecurse(Query *qry, Node *node, Oid matviewOid,
+									 Relids *relids, bool ex_lock)
+{
+	if (node == NULL)
+		return;
+
+	/* This can recurse, so check for excessive recursion */
+	check_stack_depth();
+
+	switch (nodeTag(node))
+	{
+		case T_Query:
+			{
+				Query *query = (Query *) node;
+
+				CreateIvmTriggersOnBaseTablesRecurse(qry, (Node *)query->jointree, matviewOid, relids, ex_lock);
+			}
+			break;
+
+		case T_RangeTblRef:
+			{
+				int			rti = ((RangeTblRef *) node)->rtindex;
+				RangeTblEntry *rte = rt_fetch(rti, qry->rtable);
+
+				if (rte->rtekind == RTE_RELATION && !bms_is_member(rte->relid, *relids))
+				{
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_INSERT, TRIGGER_TYPE_BEFORE, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_DELETE, TRIGGER_TYPE_BEFORE, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_UPDATE, TRIGGER_TYPE_BEFORE, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_TRUNCATE, TRIGGER_TYPE_BEFORE, true);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_INSERT, TRIGGER_TYPE_AFTER, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_DELETE, TRIGGER_TYPE_AFTER, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_UPDATE, TRIGGER_TYPE_AFTER, ex_lock);
+					CreateIvmTrigger(rte->relid, matviewOid, TRIGGER_TYPE_TRUNCATE, TRIGGER_TYPE_AFTER, true);
+
+					*relids = bms_add_member(*relids, rte->relid);
+				}
+			}
+			break;
+
+		case T_FromExpr:
+			{
+				FromExpr   *f = (FromExpr *) node;
+				ListCell   *l;
+
+				foreach(l, f->fromlist)
+					CreateIvmTriggersOnBaseTablesRecurse(qry, lfirst(l), matviewOid, relids, ex_lock);
+			}
+			break;
+
+		case T_JoinExpr:
+			{
+				JoinExpr   *j = (JoinExpr *) node;
+
+				CreateIvmTriggersOnBaseTablesRecurse(qry, j->larg, matviewOid, relids, ex_lock);
+				CreateIvmTriggersOnBaseTablesRecurse(qry, j->rarg, matviewOid, relids, ex_lock);
+			}
+			break;
+
+		default:
+			elog(ERROR, "unrecognized node type: %d", (int) nodeTag(node));
+	}
+}
+
+/*
+ * CreateIvmTrigger -- create IVM trigger on a base table
+ */
+static void
+CreateIvmTrigger(Oid relOid, Oid viewOid, int16 type, int16 timing, bool ex_lock)
+{
+	ObjectAddress	refaddr;
+	ObjectAddress	address;
+	CreateTrigStmt *ivm_trigger;
+	List *transitionRels = NIL;
+
+	Assert(timing == TRIGGER_TYPE_BEFORE || timing == TRIGGER_TYPE_AFTER);
+
+	refaddr.classId = RelationRelationId;
+	refaddr.objectId = viewOid;
+	refaddr.objectSubId = 0;
+
+	ivm_trigger = makeNode(CreateTrigStmt);
+	ivm_trigger->relation = NULL;
+	ivm_trigger->row = false;
+
+	ivm_trigger->timing = timing;
+	ivm_trigger->events = type;
+
+	switch (type)
+	{
+		case TRIGGER_TYPE_INSERT:
+			ivm_trigger->trigname = (timing == TRIGGER_TYPE_BEFORE ? "IVM_trigger_ins_before" : "IVM_trigger_ins_after");
+			break;
+		case TRIGGER_TYPE_DELETE:
+			ivm_trigger->trigname = (timing == TRIGGER_TYPE_BEFORE ? "IVM_trigger_del_before" : "IVM_trigger_del_after");
+			break;
+		case TRIGGER_TYPE_UPDATE:
+			ivm_trigger->trigname = (timing == TRIGGER_TYPE_BEFORE ? "IVM_trigger_upd_before" : "IVM_trigger_upd_after");
+			break;
+		case TRIGGER_TYPE_TRUNCATE:
+			ivm_trigger->trigname = (timing == TRIGGER_TYPE_BEFORE ? "IVM_trigger_truncate_before" : "IVM_trigger_truncate_after");
+			break;
+		default:
+			elog(ERROR, "unsupported trigger type");
+	}
+
+	if (timing == TRIGGER_TYPE_AFTER)
+	{
+		if (type == TRIGGER_TYPE_INSERT || type == TRIGGER_TYPE_UPDATE)
+		{
+			TriggerTransition *n = makeNode(TriggerTransition);
+			n->name = "__ivm_newtable";
+			n->isNew = true;
+			n->isTable = true;
+
+			transitionRels = lappend(transitionRels, n);
+		}
+		if (type == TRIGGER_TYPE_DELETE || type == TRIGGER_TYPE_UPDATE)
+		{
+			TriggerTransition *n = makeNode(TriggerTransition);
+			n->name = "__ivm_oldtable";
+			n->isNew = false;
+			n->isTable = true;
+
+			transitionRels = lappend(transitionRels, n);
+		}
+	}
+
+	/*
+	 * XXX: When using DELETE or UPDATE, we must use exclusive lock for now
+	 * because apply_old_delta(_with_count) uses ctid to identify the tuple
+	 * to be deleted/deleted, but doesn't work in concurrent situations.
+	 *
+	 * If the view doesn't have aggregate, distinct, or tuple duplicate,
+	 * then it would work even in concurrent situations. However, we don't have
+	 * any way to guarantee the view has a unique key before opening the IMMV
+	 * at the maintenance time because users may drop the unique index.
+	 */
+
+	if (type == TRIGGER_TYPE_DELETE || type == TRIGGER_TYPE_UPDATE)
+		ex_lock = true;
+
+	ivm_trigger->funcname =
+		(timing == TRIGGER_TYPE_BEFORE ? SystemFuncName("IVM_immediate_before") : SystemFuncName("IVM_immediate_maintenance"));
+
+	ivm_trigger->columns = NIL;
+	ivm_trigger->transitionRels = transitionRels;
+	ivm_trigger->whenClause = NULL;
+	ivm_trigger->isconstraint = false;
+	ivm_trigger->deferrable = false;
+	ivm_trigger->initdeferred = false;
+	ivm_trigger->constrrel = NULL;
+	ivm_trigger->args = list_make2(
+		makeString(DatumGetPointer(DirectFunctionCall1(oidout, ObjectIdGetDatum(viewOid)))),
+		makeString(DatumGetPointer(DirectFunctionCall1(boolout, BoolGetDatum(ex_lock))))
+		);
+
+	address = CreateTrigger(ivm_trigger, NULL, relOid, InvalidOid, InvalidOid,
+						 InvalidOid, InvalidOid, InvalidOid, NULL, true, false);
+
+	recordDependencyOn(&address, &refaddr, DEPENDENCY_AUTO);
+
+	/* Make changes-so-far visible */
+	CommandCounterIncrement();
+}
+
+/*
+ * check_ivm_restriction --- look for specify nodes in the query tree
+ */
+static void
+check_ivm_restriction(Node *node)
+{
+	check_ivm_restriction_walker(node, NULL);
+}
+
+static bool
+check_ivm_restriction_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	/*
+	 * We currently don't support Sub-Query.
+	 */
+	if (IsA(node, SubPlan) || IsA(node, SubLink))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("subquery is not supported on incrementally maintainable materialized view")));
+
+	/* This can recurse, so check for excessive recursion */
+	check_stack_depth();
+
+	switch (nodeTag(node))
+	{
+		case T_Query:
+			{
+				Query *qry = (Query *)node;
+				ListCell   *lc;
+				List       *vars;
+
+				/* if contained CTE, return error */
+				if (qry->cteList != NIL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("CTE is not supported on incrementally maintainable materialized view")));
+				if (qry->havingQual != NULL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg(" HAVING clause is not supported on incrementally maintainable materialized view")));
+				if (qry->sortClause != NIL)	/* There is a possibility that we don't need to return an error */
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("ORDER BY clause is not supported on incrementally maintainable materialized view")));
+				if (qry->limitOffset != NULL || qry->limitCount != NULL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("LIMIT/OFFSET clause is not supported on incrementally maintainable materialized view")));
+				if (qry->distinctClause)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("DISTINCT is not supported on incrementally maintainable materialized view")));
+				if (qry->hasDistinctOn)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("DISTINCT ON is not supported on incrementally maintainable materialized view")));
+				if (qry->hasWindowFuncs)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("window functions are not supported on incrementally maintainable materialized view")));
+				if (qry->groupingSets != NIL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("GROUPING SETS, ROLLUP, or CUBE clauses is not supported on incrementally maintainable materialized view")));
+				if (qry->setOperations != NULL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("UNION/INTERSECT/EXCEPT statements are not supported on incrementally maintainable materialized view")));
+				if (list_length(qry->targetList) == 0)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("empty target list is not supported on incrementally maintainable materialized view")));
+				if (qry->rowMarks != NIL)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("FOR UPDATE/SHARE clause is not supported on incrementally maintainable materialized view")));
+
+				/* system column restrictions */
+				vars = pull_vars_of_level((Node *) qry, 0);
+				foreach(lc, vars)
+				{
+					if (IsA(lfirst(lc), Var))
+					{
+						Var *var = (Var *) lfirst(lc);
+						/* if system column, return error */
+						if (var->varattno < 0)
+							ereport(ERROR,
+									(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+									 errmsg("system column is not supported on incrementally maintainable materialized view")));
+					}
+				}
+
+				/* restrictions for rtable */
+				foreach(lc, qry->rtable)
+				{
+					RangeTblEntry *rte = (RangeTblEntry *) lfirst(lc);
+
+					if (rte->subquery)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("subquery is not supported on incrementally maintainable materialized view")));
+
+					if (rte->tablesample != NULL)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("TABLESAMPLE clause is not supported on incrementally maintainable materialized view")));
+
+					if (rte->relkind == RELKIND_PARTITIONED_TABLE)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("partitioned table is not supported on incrementally maintainable materialized view")));
+
+					if (rte->relkind == RELKIND_RELATION && has_superclass(rte->relid))
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("partitions is not supported on incrementally maintainable materialized view")));
+
+					if (rte->relkind == RELKIND_RELATION && find_inheritance_children(rte->relid, NoLock) != NIL)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("inheritance parent is not supported on incrementally maintainable materialized view")));
+
+					if (rte->relkind == RELKIND_FOREIGN_TABLE)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("foreign table is not supported on incrementally maintainable materialized view")));
+
+					if (rte->relkind == RELKIND_VIEW ||
+						rte->relkind == RELKIND_MATVIEW)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("VIEW or MATERIALIZED VIEW is not supported on incrementally maintainable materialized view")));
+
+					if (rte->rtekind == RTE_VALUES)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("VALUES is not supported on incrementally maintainable materialized view")));
+
+				}
+
+				query_tree_walker(qry, check_ivm_restriction_walker, NULL, QTW_IGNORE_RANGE_TABLE);
+
+				break;
+			}
+		case T_TargetEntry:
+			{
+				TargetEntry *tle = (TargetEntry *)node;
+				if (isIvmName(tle->resname))
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("column name %s is not supported on incrementally maintainable materialized view", tle->resname)));
+
+				expression_tree_walker(node, check_ivm_restriction_walker, NULL);
+				break;
+			}
+		case T_JoinExpr:
+			{
+				JoinExpr *joinexpr = (JoinExpr *)node;
+
+				if (joinexpr->jointype > JOIN_INNER)
+						ereport(ERROR,
+								(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+								 errmsg("OUTER JOIN is not supported on incrementally maintainable materialized view")));
+
+				expression_tree_walker(node, check_ivm_restriction_walker, NULL);
+			}
+			break;
+		case T_Aggref:
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("aggregate function is not supported on incrementally maintainable materialized view")));
+			break;
+		default:
+			expression_tree_walker(node, check_ivm_restriction_walker, (void *) context);
+			break;
+	}
+	return false;
+}
+
+/*
+ * CreateIndexOnIMMV
+ *
+ * Create a unique index on incremental maintainable materialized view.
+ * If the view definition query has a GROUP BY clause, the index is created
+ * on the columns of GROUP BY expressions. Otherwise, if the view contains
+ * all primary key attritubes of its base tables in the target list, the index
+ * is created on these attritubes. In other cases, no index is created.
+ */
+void
+CreateIndexOnIMMV(Query *query, Relation matviewRel)
+{
+	ListCell *lc;
+	IndexStmt  *index;
+	ObjectAddress address;
+	List *constraintList = NIL;
+	char		idxname[NAMEDATALEN];
+	List	   *indexoidlist = RelationGetIndexList(matviewRel);
+	ListCell   *indexoidscan;
+	Bitmapset *key_attnos;
+
+	snprintf(idxname, sizeof(idxname), "%s_index", RelationGetRelationName(matviewRel));
+
+	index = makeNode(IndexStmt);
+
+	index->unique = true;
+	index->primary = false;
+	index->isconstraint = false;
+	index->deferrable = false;
+	index->initdeferred = false;
+	index->idxname = idxname;
+	index->relation =
+		makeRangeVar(get_namespace_name(RelationGetNamespace(matviewRel)),
+					 pstrdup(RelationGetRelationName(matviewRel)),
+					 -1);
+	index->accessMethod = DEFAULT_INDEX_TYPE;
+	index->options = NIL;
+	index->tableSpace = get_tablespace_name(matviewRel->rd_rel->reltablespace);
+	index->whereClause = NULL;
+	index->indexParams = NIL;
+	index->indexIncludingParams = NIL;
+	index->excludeOpNames = NIL;
+	index->idxcomment = NULL;
+	index->indexOid = InvalidOid;
+	index->oldNumber = InvalidRelFileNumber;
+	index->oldCreateSubid = InvalidSubTransactionId;
+	index->oldFirstRelfilelocatorSubid = InvalidSubTransactionId;
+	index->transformed = true;
+	index->concurrent = false;
+	index->if_not_exists = false;
+
+	/* create index on the base tables' primary key columns */
+	key_attnos = get_primary_key_attnos_from_query(query, &constraintList);
+	if (key_attnos)
+	{
+		foreach(lc, query->targetList)
+		{
+			TargetEntry *tle = (TargetEntry *) lfirst(lc);
+			Form_pg_attribute attr = TupleDescAttr(matviewRel->rd_att, tle->resno - 1);
+
+			if (bms_is_member(tle->resno - FirstLowInvalidHeapAttributeNumber, key_attnos))
+			{
+				IndexElem  *iparam;
+
+				iparam = makeNode(IndexElem);
+				iparam->name = pstrdup(NameStr(attr->attname));
+				iparam->expr = NULL;
+				iparam->indexcolname = NULL;
+				iparam->collation = NIL;
+				iparam->opclass = NIL;
+				iparam->opclassopts = NIL;
+				iparam->ordering = SORTBY_DEFAULT;
+				iparam->nulls_ordering = SORTBY_NULLS_DEFAULT;
+				index->indexParams = lappend(index->indexParams, iparam);
+			}
+		}
+	}
+	else
+	{
+		/* create no index, just notice that an appropriate index is necessary for efficient IVM */
+		ereport(NOTICE,
+				(errmsg("could not create an index on materialized view \"%s\" automatically",
+						RelationGetRelationName(matviewRel)),
+				 errdetail("This target list does not have all the primary key columns. "),
+				 errhint("Create an index on the materialized view for efficient incremental maintenance.")));
+		return;
+	}
+
+	/* If we have a compatible index, we don't need to create another. */
+	foreach(indexoidscan, indexoidlist)
+	{
+		Oid			indexoid = lfirst_oid(indexoidscan);
+		Relation	indexRel;
+		bool		hasCompatibleIndex = false;
+
+		indexRel = index_open(indexoid, AccessShareLock);
+
+		if (CheckIndexCompatible(indexRel->rd_id,
+								index->accessMethod,
+								index->indexParams,
+								index->excludeOpNames,
+								false))
+			hasCompatibleIndex = true;
+
+		index_close(indexRel, AccessShareLock);
+
+		if (hasCompatibleIndex)
+			return;
+	}
+
+	address = DefineIndex(RelationGetRelid(matviewRel),
+						  index,
+						  InvalidOid,
+						  InvalidOid,
+						  InvalidOid,
+						  -1,
+						  false, true, false, false, true);
+
+	ereport(NOTICE,
+			(errmsg("created index \"%s\" on materialized view \"%s\"",
+					idxname, RelationGetRelationName(matviewRel))));
+
+	/*
+	 * Make dependencies so that the index is dropped if any base tables's
+	 * primary key is dropped.
+	 */
+	foreach(lc, constraintList)
+	{
+		Oid constraintOid = lfirst_oid(lc);
+		ObjectAddress	refaddr;
+
+		refaddr.classId = ConstraintRelationId;
+		refaddr.objectId = constraintOid;
+		refaddr.objectSubId = 0;
+
+		recordDependencyOn(&address, &refaddr, DEPENDENCY_NORMAL);
+	}
+}
+
+
+/*
+ * get_primary_key_attnos_from_query
+ *
+ * Identify the columns in base tables' primary keys in the target list.
+ *
+ * Returns a Bitmapset of the column attnos of the primary key's columns of
+ * tables that used in the query.  The attnos are offset by
+ * FirstLowInvalidHeapAttributeNumber as same as get_primary_key_attnos.
+ *
+ * If any table has no primary key or any primary key's columns is not in
+ * the target list, return NULL.  We also return NULL if any pkey constraint
+ * is deferrable.
+ *
+ * constraintList is set to a list of the OIDs of the pkey constraints.
+ */
+static Bitmapset *
+get_primary_key_attnos_from_query(Query *query, List **constraintList)
+{
+	List *key_attnos_list = NIL;
+	ListCell *lc;
+	int i;
+	Bitmapset *keys = NULL;
+	Relids	rels_in_from;
+
+	/*
+	 * Collect primary key attributes from all tables used in query. The key attributes
+	 * sets for each table are stored in key_attnos_list in order by RTE index.
+	 */
+	foreach(lc, query->rtable)
+	{
+		RangeTblEntry *r = (RangeTblEntry*) lfirst(lc);
+		Bitmapset *key_attnos;
+		bool	has_pkey = true;
+
+		/* for tables, call get_primary_key_attnos */
+		if (r->rtekind == RTE_RELATION)
+		{
+			Oid constraintOid;
+			key_attnos = get_primary_key_attnos(r->relid, false, &constraintOid);
+			*constraintList = lappend_oid(*constraintList, constraintOid);
+			has_pkey = (key_attnos != NULL);
+		}
+		/* for other RTEs, store NULL into key_attnos_list */
+		else
+			key_attnos = NULL;
+
+		/*
+		 * If any table or subquery has no primary key or its pkey constraint is deferrable,
+		 * we cannot get key attributes for this query, so return NULL.
+		 */
+		if (!has_pkey)
+			return NULL;
+
+		key_attnos_list = lappend(key_attnos_list, key_attnos);
+	}
+
+	/* Collect key attributes appearing in the target list */
+	i = 1;
+	foreach(lc, query->targetList)
+	{
+		TargetEntry *tle = (TargetEntry *) flatten_join_alias_vars(NULL, query, lfirst(lc));
+
+		if (IsA(tle->expr, Var))
+		{
+			Var *var = (Var*) tle->expr;
+			Bitmapset *key_attnos = list_nth(key_attnos_list, var->varno - 1);
+
+			/* check if this attribute is from a base table's primary key */
+			if (bms_is_member(var->varattno - FirstLowInvalidHeapAttributeNumber, key_attnos))
+			{
+				/*
+				 * Remove found key attributes from key_attnos_list, and add this
+				 * to the result list.
+				 */
+				key_attnos = bms_del_member(key_attnos, var->varattno - FirstLowInvalidHeapAttributeNumber);
+				if (bms_is_empty(key_attnos))
+				{
+					key_attnos_list = list_delete_nth_cell(key_attnos_list, var->varno - 1);
+					key_attnos_list = list_insert_nth(key_attnos_list, var->varno - 1, NULL);
+				}
+				keys = bms_add_member(keys, i - FirstLowInvalidHeapAttributeNumber);
+			}
+		}
+		i++;
+	}
+
+	/* Collect RTE indexes of relations appearing in the FROM clause */
+	rels_in_from = get_relids_in_jointree((Node *) query->jointree, false, false);
+
+	/*
+	 * Check if all key attributes of relations in FROM are appearing in the target
+	 * list.  If an attribute remains in key_attnos_list in spite of the table is used
+	 * in FROM clause, the target is missing this key attribute, so we return NULL.
+	 */
+	i = 1;
+	foreach(lc, key_attnos_list)
+	{
+		Bitmapset *bms = (Bitmapset *)lfirst(lc);
+		if (!bms_is_empty(bms) && bms_is_member(i, rels_in_from))
+			return NULL;
+		i++;
+	}
+
+	return keys;
+}
diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c
index 9ec13d0984..78a5dd1df9 100644
--- a/src/backend/commands/matview.c
+++ b/src/backend/commands/matview.c
@@ -23,23 +23,35 @@
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_am.h"
+#include "catalog/pg_depend.h"
+#include "catalog/pg_trigger.h"
 #include "catalog/pg_opclass.h"
 #include "commands/cluster.h"
 #include "commands/matview.h"
 #include "commands/tablecmds.h"
 #include "commands/tablespace.h"
+#include "commands/createas.h"
 #include "executor/executor.h"
 #include "executor/spi.h"
+#include "executor/tstoreReceiver.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "parser/analyze.h"
+#include "parser/parse_clause.h"
+#include "parser/parse_func.h"
+#include "parser/parse_relation.h"
 #include "pgstat.h"
 #include "rewrite/rewriteHandler.h"
+#include "rewrite/rowsecurity.h"
 #include "storage/lmgr.h"
 #include "tcop/tcopprot.h"
 #include "utils/builtins.h"
+#include "utils/fmgroids.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
 #include "utils/syscache.h"
+#include "utils/typcache.h"
 
 
 typedef struct
@@ -53,6 +65,52 @@ typedef struct
 	BulkInsertState bistate;	/* bulk insert state */
 } DR_transientrel;
 
+#define MV_INIT_QUERYHASHSIZE	16
+
+/*
+ * MV_TriggerHashEntry
+ *
+ * Hash entry for base tables on which IVM trigger is invoked
+ */
+typedef struct MV_TriggerHashEntry
+{
+	Oid	matview_id;			/* OID of the materialized view */
+	int	before_trig_count;	/* count of before triggers invoked */
+	int	after_trig_count;	/* count of after triggers invoked */
+
+	Snapshot	snapshot;	/* Snapshot just before table change */
+
+	List   *tables;		/* List of MV_TriggerTable */
+	bool	has_old;	/* tuples are deleted from any table? */
+	bool	has_new;	/* tuples are inserted into any table? */
+} MV_TriggerHashEntry;
+
+/*
+ * MV_TriggerTable
+ *
+ * IVM related data for tables on which the trigger is invoked.
+ */
+typedef struct MV_TriggerTable
+{
+	Oid		table_id;			/* OID of the modified table */
+	List   *old_tuplestores;	/* tuplestores for deleted tuples */
+	List   *new_tuplestores;	/* tuplestores for inserted tuples */
+
+	List   *rte_indexes;		/* List of RTE index of the modified table */
+	RangeTblEntry *original_rte;	/* the original RTE saved before rewriting query */
+
+	Relation	rel;			/* relation of the modified table */
+	TupleTableSlot *slot;		/* for checking visibility in the pre-state table */
+} MV_TriggerTable;
+
+static HTAB *mv_trigger_info = NULL;
+
+static bool in_delta_calculation = false;
+
+/* ENR name for materialized view delta */
+#define NEW_DELTA_ENRNAME "new_delta"
+#define OLD_DELTA_ENRNAME "old_delta"
+
 static int	matview_maintenance_depth = 0;
 
 static void transientrel_startup(DestReceiver *self, int operation, TupleDesc typeinfo);
@@ -60,7 +118,9 @@ static bool transientrel_receive(TupleTableSlot *slot, DestReceiver *self);
 static void transientrel_shutdown(DestReceiver *self);
 static void transientrel_destroy(DestReceiver *self);
 static uint64 refresh_matview_datafill(DestReceiver *dest, Query *query,
-									   const char *queryString);
+						 QueryEnvironment *queryEnv,
+						 TupleDesc *resultTupleDesc,
+						 const char *queryString);
 static char *make_temptable_name_n(char *tempname, int n);
 static void refresh_by_match_merge(Oid matviewOid, Oid tempOid, Oid relowner,
 								   int save_sec_context);
@@ -68,6 +128,37 @@ static void refresh_by_heap_swap(Oid matviewOid, Oid OIDNewHeap, char relpersist
 static bool is_usable_unique_index(Relation indexRel);
 static void OpenMatViewIncrementalMaintenance(void);
 static void CloseMatViewIncrementalMaintenance(void);
+static Query *get_matview_query(Relation matviewRel);
+
+static Query *rewrite_query_for_preupdate_state(Query *query, List *tables,
+								  ParseState *pstate, Oid matviewid);
+static void register_delta_ENRs(ParseState *pstate, Query *query, List *tables);
+static char *make_delta_enr_name(const char *prefix, Oid relid, int count);
+static RangeTblEntry *get_prestate_rte(RangeTblEntry *rte, MV_TriggerTable *table,
+				 QueryEnvironment *queryEnv, Oid matviewid);
+static RangeTblEntry *replace_rte_with_delta(RangeTblEntry *rte, MV_TriggerTable *table, bool is_new,
+		   QueryEnvironment *queryEnv);
+static Query *rewrite_query_for_counting(Query *query, ParseState *pstate);
+
+static void calc_delta(MV_TriggerTable *table, int rte_index, Query *query,
+			DestReceiver *dest_old, DestReceiver *dest_new,
+			TupleDesc *tupdesc_old, TupleDesc *tupdesc_new,
+			QueryEnvironment *queryEnv);
+static Query *rewrite_query_for_postupdate_state(Query *query, MV_TriggerTable *table, int rte_index);
+
+static void apply_delta(Oid matviewOid, Tuplestorestate *old_tuplestores, Tuplestorestate *new_tuplestores,
+			TupleDesc tupdesc_old, TupleDesc tupdesc_new,
+			Query *query);
+static void apply_old_delta(const char *matviewname, const char *deltaname_old,
+				List *keys);
+static void apply_new_delta(const char *matviewname, const char *deltaname_new,
+				StringInfo target_list);
+static char *get_matching_condition_string(List *keys);
+static void generate_equal(StringInfo querybuf, Oid opttype,
+			   const char *leftop, const char *rightop);
+
+static void mv_InitHashTables(void);
+static void clean_up_IVM_hash_entry(MV_TriggerHashEntry *entry, bool is_abort);
 
 /*
  * SetMatViewPopulatedState
@@ -109,6 +200,46 @@ SetMatViewPopulatedState(Relation relation, bool newstate)
 	CommandCounterIncrement();
 }
 
+/*
+ * SetMatViewIVMState
+ *		Mark a materialized view as IVM, or not.
+ *
+ * NOTE: caller must be holding an appropriate lock on the relation.
+ */
+void
+SetMatViewIVMState(Relation relation, bool newstate)
+{
+	Relation	pgrel;
+	HeapTuple	tuple;
+
+	Assert(relation->rd_rel->relkind == RELKIND_MATVIEW);
+
+	/*
+	 * Update relation's pg_class entry.  Crucial side-effect: other backends
+	 * (and this one too!) are sent SI message to make them rebuild relcache
+	 * entries.
+	 */
+	pgrel = table_open(RelationRelationId, RowExclusiveLock);
+	tuple = SearchSysCacheCopy1(RELOID,
+								ObjectIdGetDatum(RelationGetRelid(relation)));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation %u",
+			 RelationGetRelid(relation));
+
+	((Form_pg_class) GETSTRUCT(tuple))->relisivm = newstate;
+
+	CatalogTupleUpdate(pgrel, &tuple->t_self, tuple);
+
+	heap_freetuple(tuple);
+	table_close(pgrel, RowExclusiveLock);
+
+	/*
+	 * Advance command counter to make the updated pg_class row locally
+	 * visible.
+	 */
+	CommandCounterIncrement();
+}
+
 /*
  * ExecRefreshMatView -- execute a REFRESH MATERIALIZED VIEW command
  *
@@ -135,8 +266,6 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 {
 	Oid			matviewOid;
 	Relation	matviewRel;
-	RewriteRule *rule;
-	List	   *actions;
 	Query	   *dataQuery;
 	Oid			tableSpace;
 	Oid			relowner;
@@ -150,6 +279,7 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 	int			save_sec_context;
 	int			save_nestlevel;
 	ObjectAddress address;
+	bool oldPopulated;
 
 	/* Determine strength of lock needed. */
 	concurrent = stmt->concurrent;
@@ -176,6 +306,8 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 	save_nestlevel = NewGUCNestLevel();
 	RestrictSearchPath();
 
+	oldPopulated = RelationIsPopulated(matviewRel);
+
 	/* Make sure it is a materialized view. */
 	if (matviewRel->rd_rel->relkind != RELKIND_MATVIEW)
 		ereport(ERROR,
@@ -196,32 +328,9 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 				 errmsg("%s and %s options cannot be used together",
 						"CONCURRENTLY", "WITH NO DATA")));
 
-	/*
-	 * Check that everything is correct for a refresh. Problems at this point
-	 * are internal errors, so elog is sufficient.
-	 */
-	if (matviewRel->rd_rel->relhasrules == false ||
-		matviewRel->rd_rules->numLocks < 1)
-		elog(ERROR,
-			 "materialized view \"%s\" is missing rewrite information",
-			 RelationGetRelationName(matviewRel));
-
-	if (matviewRel->rd_rules->numLocks > 1)
-		elog(ERROR,
-			 "materialized view \"%s\" has too many rules",
-			 RelationGetRelationName(matviewRel));
 
-	rule = matviewRel->rd_rules->rules[0];
-	if (rule->event != CMD_SELECT || !(rule->isInstead))
-		elog(ERROR,
-			 "the rule for materialized view \"%s\" is not a SELECT INSTEAD OF rule",
-			 RelationGetRelationName(matviewRel));
+	dataQuery = get_matview_query(matviewRel);
 
-	actions = rule->actions;
-	if (list_length(actions) != 1)
-		elog(ERROR,
-			 "the rule for materialized view \"%s\" is not a single action",
-			 RelationGetRelationName(matviewRel));
 
 	/*
 	 * Check that there is a unique index with no WHERE clause on one or more
@@ -256,12 +365,6 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 					 errhint("Create a unique index with no WHERE clause on one or more columns of the materialized view.")));
 	}
 
-	/*
-	 * The stored query was rewritten at the time of the MV definition, but
-	 * has not been scribbled on by the planner.
-	 */
-	dataQuery = linitial_node(Query, actions);
-
 	/*
 	 * Check for active uses of the relation in the current transaction, such
 	 * as open scans.
@@ -289,6 +392,74 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 		relpersistence = matviewRel->rd_rel->relpersistence;
 	}
 
+	/* delete IMMV triggers. */
+	if (RelationIsIVM(matviewRel) && stmt->skipData )
+	{
+		Relation	tgRel;
+		Relation	depRel;
+		ScanKeyData key;
+		SysScanDesc scan;
+		HeapTuple	tup;
+		ObjectAddresses *immv_triggers;
+
+		immv_triggers = new_object_addresses();
+
+		tgRel = table_open(TriggerRelationId, RowExclusiveLock);
+		depRel = table_open(DependRelationId, RowExclusiveLock);
+
+		/* search triggers that depends on IMMV. */
+		ScanKeyInit(&key,
+					Anum_pg_depend_refobjid,
+					BTEqualStrategyNumber, F_OIDEQ,
+					ObjectIdGetDatum(matviewOid));
+		scan = systable_beginscan(depRel, DependReferenceIndexId, true,
+								  NULL, 1, &key);
+		while ((tup = systable_getnext(scan)) != NULL)
+		{
+			ObjectAddress obj;
+			Form_pg_depend foundDep = (Form_pg_depend) GETSTRUCT(tup);
+
+			if (foundDep->classid == TriggerRelationId)
+			{
+				HeapTuple	tgtup;
+				ScanKeyData tgkey[1];
+				SysScanDesc tgscan;
+				Form_pg_trigger tgform;
+
+				/* Find the trigger name. */
+				ScanKeyInit(&tgkey[0],
+							Anum_pg_trigger_oid,
+							BTEqualStrategyNumber, F_OIDEQ,
+							ObjectIdGetDatum(foundDep->objid));
+
+				tgscan = systable_beginscan(tgRel, TriggerOidIndexId, true,
+											NULL, 1, tgkey);
+				tgtup = systable_getnext(tgscan);
+				if (!HeapTupleIsValid(tgtup))
+					elog(ERROR, "could not find tuple for immv trigger %u", foundDep->objid);
+
+				tgform = (Form_pg_trigger) GETSTRUCT(tgtup);
+
+				/* If trigger is created by IMMV, delete it. */
+				if (strncmp(NameStr(tgform->tgname), "IVM_trigger_", 12) == 0)
+				{
+					obj.classId = foundDep->classid;
+					obj.objectId = foundDep->objid;
+					obj.objectSubId = foundDep->refobjsubid;
+					add_exact_object_address(&obj, immv_triggers);
+				}
+				systable_endscan(tgscan);
+			}
+		}
+		systable_endscan(scan);
+
+		performMultipleDeletions(immv_triggers, DROP_RESTRICT, PERFORM_DELETION_INTERNAL);
+
+		table_close(depRel, RowExclusiveLock);
+		table_close(tgRel, RowExclusiveLock);
+		free_object_addresses(immv_triggers);
+	}
+
 	/*
 	 * Create the transient table that will receive the regenerated data. Lock
 	 * it against access by any other process until commit (by which time it
@@ -302,7 +473,7 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 
 	/* Generate the data, if wanted. */
 	if (!stmt->skipData)
-		processed = refresh_matview_datafill(dest, dataQuery, queryString);
+		processed = refresh_matview_datafill(dest, dataQuery, NULL, NULL, queryString);
 
 	/* Make the matview match the newly generated data. */
 	if (concurrent)
@@ -337,6 +508,12 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 			pgstat_count_heap_insert(matviewRel, processed);
 	}
 
+	if (!stmt->skipData && RelationIsIVM(matviewRel) && !oldPopulated)
+	{
+		CreateIndexOnIMMV(dataQuery, matviewRel);
+		CreateIvmTriggersOnBaseTables(dataQuery, matviewOid);
+	}
+
 	table_close(matviewRel, NoLock);
 
 	/* Roll back any GUC changes */
@@ -371,6 +548,8 @@ ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
  */
 static uint64
 refresh_matview_datafill(DestReceiver *dest, Query *query,
+						 QueryEnvironment *queryEnv,
+						 TupleDesc *resultTupleDesc,
 						 const char *queryString)
 {
 	List	   *rewritten;
@@ -407,7 +586,7 @@ refresh_matview_datafill(DestReceiver *dest, Query *query,
 	/* Create a QueryDesc, redirecting output to our tuple receiver */
 	queryDesc = CreateQueryDesc(plan, queryString,
 								GetActiveSnapshot(), InvalidSnapshot,
-								dest, NULL, NULL, 0);
+								dest, NULL, queryEnv ? queryEnv: NULL, 0);
 
 	/* call ExecutorStart to prepare the plan for execution */
 	ExecutorStart(queryDesc, 0);
@@ -417,6 +596,9 @@ refresh_matview_datafill(DestReceiver *dest, Query *query,
 
 	processed = queryDesc->estate->es_processed;
 
+	if (resultTupleDesc)
+		*resultTupleDesc = CreateTupleDescCopy(queryDesc->tupDesc);
+
 	/* and clean up */
 	ExecutorFinish(queryDesc);
 	ExecutorEnd(queryDesc);
@@ -952,3 +1134,1219 @@ CloseMatViewIncrementalMaintenance(void)
 	matview_maintenance_depth--;
 	Assert(matview_maintenance_depth >= 0);
 }
+
+/*
+ * get_matview_query - get the Query from a matview's _RETURN rule.
+ */
+static Query *
+get_matview_query(Relation matviewRel)
+{
+	RewriteRule *rule;
+	List * actions;
+
+	/*
+	 * Check that everything is correct for a refresh. Problems at this point
+	 * are internal errors, so elog is sufficient.
+	 */
+	if (matviewRel->rd_rel->relhasrules == false ||
+		matviewRel->rd_rules->numLocks < 1)
+		elog(ERROR,
+			 "materialized view \"%s\" is missing rewrite information",
+			 RelationGetRelationName(matviewRel));
+
+	if (matviewRel->rd_rules->numLocks > 1)
+		elog(ERROR,
+			 "materialized view \"%s\" has too many rules",
+			 RelationGetRelationName(matviewRel));
+
+	rule = matviewRel->rd_rules->rules[0];
+	if (rule->event != CMD_SELECT || !(rule->isInstead))
+		elog(ERROR,
+			 "the rule for materialized view \"%s\" is not a SELECT INSTEAD OF rule",
+			 RelationGetRelationName(matviewRel));
+
+	actions = rule->actions;
+	if (list_length(actions) != 1)
+		elog(ERROR,
+			 "the rule for materialized view \"%s\" is not a single action",
+			 RelationGetRelationName(matviewRel));
+
+	/*
+	 * The stored query was rewritten at the time of the MV definition, but
+	 * has not been scribbled on by the planner.
+	 */
+	return linitial_node(Query, actions);
+}
+
+
+/* ----------------------------------------------------
+ *		Incremental View Maintenance routines
+ * ---------------------------------------------------
+ */
+
+/*
+ * IVM_immediate_before
+ *
+ * IVM trigger function invoked before base table is modified. If this is
+ * invoked firstly in the same statement, we save the transaction id and the
+ * command id at that time.
+ */
+Datum
+IVM_immediate_before(PG_FUNCTION_ARGS)
+{
+	TriggerData *trigdata = (TriggerData *) fcinfo->context;
+	char	   *matviewOid_text = trigdata->tg_trigger->tgargs[0];
+	char	   *ex_lock_text = trigdata->tg_trigger->tgargs[1];
+	Oid			matviewOid;
+	MV_TriggerHashEntry *entry;
+	bool	found;
+	bool	ex_lock;
+
+	matviewOid = DatumGetObjectId(DirectFunctionCall1(oidin, CStringGetDatum(matviewOid_text)));
+	ex_lock = DatumGetBool(DirectFunctionCall1(boolin, CStringGetDatum(ex_lock_text)));
+
+	/* If the view has more than one tables, we have to use an exclusive lock. */
+	if (ex_lock)
+	{
+		/*
+		 * Wait for concurrent transactions which update this materialized view at
+		 * READ COMMITED. This is needed to see changes committed in other
+		 * transactions. No wait and raise an error at REPEATABLE READ or
+		 * SERIALIZABLE to prevent update anomalies of matviews.
+		 * XXX: dead-lock is possible here.
+		 */
+		if (!IsolationUsesXactSnapshot())
+			LockRelationOid(matviewOid, ExclusiveLock);
+		else if (!ConditionalLockRelationOid(matviewOid, ExclusiveLock))
+		{
+			/* try to throw error by name; relation could be deleted... */
+			char	   *relname = get_rel_name(matviewOid);
+
+			if (!relname)
+				ereport(ERROR,
+						(errcode(ERRCODE_LOCK_NOT_AVAILABLE),
+						errmsg("could not obtain lock on materialized view during incremental maintenance")));
+
+			ereport(ERROR,
+					(errcode(ERRCODE_LOCK_NOT_AVAILABLE),
+					errmsg("could not obtain lock on materialized view \"%s\" during incremental maintenance",
+							relname)));
+		}
+	}
+	else
+		LockRelationOid(matviewOid, RowExclusiveLock);
+
+	/*
+	 * On the first call initialize the hashtable
+	 */
+	if (!mv_trigger_info)
+		mv_InitHashTables();
+
+	entry = (MV_TriggerHashEntry *) hash_search(mv_trigger_info,
+											  (void *) &matviewOid,
+											  HASH_ENTER, &found);
+
+	/* On the first BEFORE to update the view, initialize trigger data */
+	if (!found)
+	{
+		/*
+		 * Get a snapshot just before the table was modified for checking
+		 * tuple visibility in the pre-update state of the table.
+		 */
+		Snapshot snapshot = GetActiveSnapshot();
+
+		entry->matview_id = matviewOid;
+		entry->before_trig_count = 0;
+		entry->after_trig_count = 0;
+		entry->snapshot = RegisterSnapshot(snapshot);
+		entry->tables = NIL;
+		entry->has_old = false;
+		entry->has_new = false;
+	}
+
+	entry->before_trig_count++;
+
+	return PointerGetDatum(NULL);
+}
+
+/*
+ * IVM_immediate_maintenance
+ *
+ * IVM trigger function invoked after base table is modified.
+ * For each table, tuplestores of transition tables are collected.
+ * and after the last modification
+ */
+Datum
+IVM_immediate_maintenance(PG_FUNCTION_ARGS)
+{
+	TriggerData *trigdata = (TriggerData *) fcinfo->context;
+	Relation	rel;
+	Oid			relid;
+	Oid			matviewOid;
+	Query	   *query;
+	Query	   *rewritten = NULL;
+	char	   *matviewOid_text = trigdata->tg_trigger->tgargs[0];
+	Relation	matviewRel;
+	int old_depth = matview_maintenance_depth;
+
+	Oid			relowner;
+	Tuplestorestate *old_tuplestore = NULL;
+	Tuplestorestate *new_tuplestore = NULL;
+	DestReceiver *dest_new = NULL, *dest_old = NULL;
+	Oid			save_userid;
+	int			save_sec_context;
+	int			save_nestlevel;
+
+	MV_TriggerHashEntry *entry;
+	MV_TriggerTable		*table;
+	bool	found;
+
+	ParseState		 *pstate;
+	QueryEnvironment *queryEnv = create_queryEnv();
+	MemoryContext	oldcxt;
+	ListCell   *lc;
+	int			i;
+
+
+	/* Create a ParseState for rewriting the view definition query */
+	pstate = make_parsestate(NULL);
+	pstate->p_queryEnv = queryEnv;
+	pstate->p_expr_kind = EXPR_KIND_SELECT_TARGET;
+
+	rel = trigdata->tg_relation;
+	relid = rel->rd_id;
+
+	matviewOid = DatumGetObjectId(DirectFunctionCall1(oidin, CStringGetDatum(matviewOid_text)));
+
+	/*
+	 * On the first call initialize the hashtable
+	 */
+	if (!mv_trigger_info)
+		mv_InitHashTables();
+
+	/* get the entry for this materialized view */
+	entry = (MV_TriggerHashEntry *) hash_search(mv_trigger_info,
+											  (void *) &matviewOid,
+											  HASH_FIND, &found);
+	Assert (found && entry != NULL);
+	entry->after_trig_count++;
+
+	/* search the entry for the modified table and create new entry if not found */
+	found = false;
+	foreach(lc, entry->tables)
+	{
+		table = (MV_TriggerTable *) lfirst(lc);
+		if (table->table_id == relid)
+		{
+			found = true;
+			break;
+		}
+	}
+	if (!found)
+	{
+		oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+
+		table = (MV_TriggerTable *) palloc0(sizeof(MV_TriggerTable));
+		table->table_id = relid;
+		table->old_tuplestores = NIL;
+		table->new_tuplestores = NIL;
+		table->rte_indexes = NIL;
+		table->slot = MakeSingleTupleTableSlot(RelationGetDescr(rel), table_slot_callbacks(rel));
+		table->rel = table_open(RelationGetRelid(rel), NoLock);
+		entry->tables = lappend(entry->tables, table);
+
+		MemoryContextSwitchTo(oldcxt);
+	}
+
+	/* Save the transition tables and make a request to not free immediately */
+	if (trigdata->tg_oldtable)
+	{
+		oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+		table->old_tuplestores = lappend(table->old_tuplestores, trigdata->tg_oldtable);
+		entry->has_old = true;
+		MemoryContextSwitchTo(oldcxt);
+	}
+	if (trigdata->tg_newtable)
+	{
+		oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+		table->new_tuplestores = lappend(table->new_tuplestores, trigdata->tg_newtable);
+		entry->has_new = true;
+		MemoryContextSwitchTo(oldcxt);
+	}
+	if (entry->has_new || entry->has_old)
+	{
+		CmdType cmd;
+
+		if (TRIGGER_FIRED_BY_INSERT(trigdata->tg_event))
+			cmd = CMD_INSERT;
+		else if (TRIGGER_FIRED_BY_DELETE(trigdata->tg_event))
+			cmd = CMD_DELETE;
+		else if (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event))
+			cmd = CMD_UPDATE;
+		else
+			elog(ERROR,"unsupported trigger type");
+
+		/* Prolong lifespan of transition tables to the end of the last AFTER trigger */
+		SetTransitionTablePreserved(relid, cmd);
+	}
+
+
+	/* If this is not the last AFTER trigger call, immediately exit. */
+	Assert (entry->before_trig_count >= entry->after_trig_count);
+	if (entry->before_trig_count != entry->after_trig_count)
+		return PointerGetDatum(NULL);
+
+	/*
+	 * If this is the last AFTER trigger call, continue and update the view.
+	 */
+
+	/*
+	 * Advance command counter to make the updated base table row locally
+	 * visible.
+	 */
+	CommandCounterIncrement();
+
+	matviewRel = table_open(matviewOid, NoLock);
+
+	/* Make sure it is a materialized view. */
+	Assert(matviewRel->rd_rel->relkind == RELKIND_MATVIEW);
+
+	/*
+	 * Get and push the latast snapshot to see any changes which is committed
+	 * during waiting in other transactions at READ COMMITTED level.
+	 */
+	PushActiveSnapshot(GetTransactionSnapshot());
+
+	/*
+	 * Check for active uses of the relation in the current transaction, such
+	 * as open scans.
+	 *
+	 * NB: We count on this to protect us against problems with refreshing the
+	 * data using TABLE_INSERT_FROZEN.
+	 */
+	CheckTableNotInUse(matviewRel, "refresh a materialized view incrementally");
+
+	/*
+	 * Switch to the owner's userid, so that any functions are run as that
+	 * user.  Also arrange to make GUC variable changes local to this command.
+	 * We will switch modes when we are about to execute user code.
+	 */
+	relowner = matviewRel->rd_rel->relowner;
+	GetUserIdAndSecContext(&save_userid, &save_sec_context);
+	SetUserIdAndSecContext(relowner,
+						   save_sec_context | SECURITY_RESTRICTED_OPERATION);
+	save_nestlevel = NewGUCNestLevel();
+
+	/* get view query*/
+	query = get_matview_query(matviewRel);
+
+	/*
+	 * When a base table is truncated, the view content will be empty if the
+	 * view definition query does not contain an aggregate without a GROUP clause.
+	 * Therefore, such views can be truncated.
+	 */
+	if (TRIGGER_FIRED_BY_TRUNCATE(trigdata->tg_event))
+	{
+		ExecuteTruncateGuts(list_make1(matviewRel), list_make1_oid(matviewOid),
+							NIL, DROP_RESTRICT, false, false);
+
+		/* Clean up hash entry and delete tuplestores */
+		clean_up_IVM_hash_entry(entry, false);
+
+		/* Pop the original snapshot. */
+		PopActiveSnapshot();
+
+		table_close(matviewRel, NoLock);
+
+		/* Roll back any GUC changes */
+		AtEOXact_GUC(false, save_nestlevel);
+
+		/* Restore userid and security context */
+		SetUserIdAndSecContext(save_userid, save_sec_context);
+
+		return PointerGetDatum(NULL);
+	}
+
+	/*
+	 * rewrite query for calculating deltas
+	 */
+
+	rewritten = copyObject(query);
+
+	/* Replace resnames in a target list with materialized view's attnames */
+	i = 0;
+	foreach (lc, rewritten->targetList)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Form_pg_attribute attr = TupleDescAttr(matviewRel->rd_att, i);
+		char *resname = NameStr(attr->attname);
+
+		tle->resname = pstrdup(resname);
+		i++;
+	}
+
+	/* Set all tables in the query to pre-update state */
+	rewritten = rewrite_query_for_preupdate_state(rewritten, entry->tables,
+												  pstate, matviewOid);
+	/* Rewrite for counting duplicated tuples */
+	rewritten = rewrite_query_for_counting(rewritten, pstate);
+
+	/* Create tuplestores to store view deltas */
+	if (entry->has_old)
+	{
+		oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+
+		old_tuplestore = tuplestore_begin_heap(false, false, work_mem);
+		dest_old = CreateDestReceiver(DestTuplestore);
+		SetTuplestoreDestReceiverParams(dest_old,
+									old_tuplestore,
+									TopTransactionContext,
+									false,
+									NULL,
+									NULL);
+
+		MemoryContextSwitchTo(oldcxt);
+	}
+	if (entry->has_new)
+	{
+		oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+
+		new_tuplestore = tuplestore_begin_heap(false, false, work_mem);
+		dest_new = CreateDestReceiver(DestTuplestore);
+		SetTuplestoreDestReceiverParams(dest_new,
+									new_tuplestore,
+									TopTransactionContext,
+									false,
+									NULL,
+									NULL);
+		MemoryContextSwitchTo(oldcxt);
+	}
+
+	/* for all modified tables */
+	foreach(lc, entry->tables)
+	{
+		ListCell *lc2;
+
+		table = (MV_TriggerTable *) lfirst(lc);
+
+		/* loop for self-join */
+		foreach(lc2, table->rte_indexes)
+		{
+			int	rte_index = lfirst_int(lc2);
+			TupleDesc		tupdesc_old;
+			TupleDesc		tupdesc_new;
+
+			/* calculate delta tables */
+			calc_delta(table, rte_index, rewritten, dest_old, dest_new,
+					   &tupdesc_old, &tupdesc_new, queryEnv);
+
+			/* Set the table in the query to post-update state */
+			rewritten = rewrite_query_for_postupdate_state(rewritten, table, rte_index);
+
+			PG_TRY();
+			{
+				/* apply the delta tables to the materialized view */
+				apply_delta(matviewOid, old_tuplestore, new_tuplestore,
+							tupdesc_old, tupdesc_new, query);
+			}
+			PG_CATCH();
+			{
+				matview_maintenance_depth = old_depth;
+				PG_RE_THROW();
+			}
+			PG_END_TRY();
+
+			/* clear view delta tuplestores */
+			if (old_tuplestore)
+				tuplestore_clear(old_tuplestore);
+			if (new_tuplestore)
+				tuplestore_clear(new_tuplestore);
+		}
+	}
+
+	/* Clean up hash entry and delete tuplestores */
+	clean_up_IVM_hash_entry(entry, false);
+	if (old_tuplestore)
+	{
+		dest_old->rDestroy(dest_old);
+		tuplestore_end(old_tuplestore);
+	}
+	if (new_tuplestore)
+	{
+		dest_new->rDestroy(dest_new);
+		tuplestore_end(new_tuplestore);
+	}
+
+	/* Pop the original snapshot. */
+	PopActiveSnapshot();
+
+	table_close(matviewRel, NoLock);
+
+	/* Roll back any GUC changes */
+	AtEOXact_GUC(false, save_nestlevel);
+
+	/* Restore userid and security context */
+	SetUserIdAndSecContext(save_userid, save_sec_context);
+
+	return PointerGetDatum(NULL);
+}
+
+/*
+ * rewrite_query_for_preupdate_state
+ *
+ * Rewrite the query so that base tables' RTEs will represent "pre-update"
+ * state of tables. This is necessary to calculate view delta after multiple
+ * tables are modified.
+ */
+static Query*
+rewrite_query_for_preupdate_state(Query *query, List *tables,
+								  ParseState *pstate, Oid matviewid)
+{
+	ListCell *lc;
+	int num_rte = list_length(query->rtable);
+	int i;
+
+
+	/* register delta ENRs */
+	register_delta_ENRs(pstate, query, tables);
+
+	/* XXX: Is necessary? Is this right timing? */
+	AcquireRewriteLocks(query, true, false);
+
+	i = 1;
+	foreach(lc, query->rtable)
+	{
+		RangeTblEntry *r = (RangeTblEntry*) lfirst(lc);
+
+		ListCell *lc2;
+		foreach(lc2, tables)
+		{
+			MV_TriggerTable *table = (MV_TriggerTable *) lfirst(lc2);
+			/*
+			 * if the modified table is found then replace the original RTE with
+			 * "pre-state" RTE and append its index to the list.
+			 */
+			if (r->relid == table->table_id)
+			{
+				List *securityQuals;
+				List *withCheckOptions;
+				bool  hasRowSecurity;
+				bool  hasSubLinks;
+
+				RangeTblEntry *rte_pre = get_prestate_rte(r, table, pstate->p_queryEnv, matviewid);
+
+				/*
+				 * Set a row security poslicies of the modified table to the subquery RTE which
+				 * represents the pre-update state of the table.
+				 */
+				get_row_security_policies(query, table->original_rte, i,
+										  &securityQuals, &withCheckOptions,
+										  &hasRowSecurity, &hasSubLinks);
+
+				if (hasRowSecurity)
+				{
+					query->hasRowSecurity = true;
+					rte_pre->security_barrier = true;
+				}
+				if (hasSubLinks)
+					query->hasSubLinks = true;
+
+				rte_pre->securityQuals = securityQuals;
+				lfirst(lc) = rte_pre;
+
+				table->rte_indexes = lappend_int(table->rte_indexes, i);
+				break;
+			}
+		}
+
+		/* finish the loop if we processed all RTE included in the original query */
+		if (i++ >= num_rte)
+			break;
+	}
+
+	return query;
+}
+
+/*
+ * register_delta_ENRs
+ *
+ * For all modified tables, make ENRs for their transition tables
+ * and register them to the queryEnv. ENR's RTEs are also appended
+ * into the list in query tree.
+ */
+static void
+register_delta_ENRs(ParseState *pstate, Query *query, List *tables)
+{
+	QueryEnvironment *queryEnv = pstate->p_queryEnv;
+	ListCell *lc;
+	RangeTblEntry	*rte;
+
+	foreach(lc, tables)
+	{
+		MV_TriggerTable *table = (MV_TriggerTable *) lfirst(lc);
+		ListCell *lc2;
+		int count;
+
+		count = 0;
+		foreach(lc2, table->old_tuplestores)
+		{
+			Tuplestorestate *oldtable = (Tuplestorestate *) lfirst(lc2);
+			EphemeralNamedRelation enr =
+				palloc(sizeof(EphemeralNamedRelationData));
+			ParseNamespaceItem *nsitem;
+
+			enr->md.name = make_delta_enr_name("old", table->table_id, count);
+			enr->md.reliddesc = table->table_id;
+			enr->md.tupdesc = NULL;
+			enr->md.enrtype = ENR_NAMED_TUPLESTORE;
+			enr->md.enrtuples = tuplestore_tuple_count(oldtable);
+			enr->reldata = oldtable;
+			register_ENR(queryEnv, enr);
+
+			nsitem = addRangeTableEntryForENR(pstate, makeRangeVar(NULL, enr->md.name, -1), true);
+			rte = nsitem->p_rte;
+
+			query->rtable = lappend(query->rtable, rte);
+
+			count++;
+		}
+
+		count = 0;
+		foreach(lc2, table->new_tuplestores)
+		{
+			Tuplestorestate *newtable = (Tuplestorestate *) lfirst(lc2);
+			EphemeralNamedRelation enr =
+				palloc(sizeof(EphemeralNamedRelationData));
+			ParseNamespaceItem *nsitem;
+
+			enr->md.name = make_delta_enr_name("new", table->table_id, count);
+			enr->md.reliddesc = table->table_id;
+			enr->md.tupdesc = NULL;
+			enr->md.enrtype = ENR_NAMED_TUPLESTORE;
+			enr->md.enrtuples = tuplestore_tuple_count(newtable);
+			enr->reldata = newtable;
+			register_ENR(queryEnv, enr);
+
+			nsitem = addRangeTableEntryForENR(pstate, makeRangeVar(NULL, enr->md.name, -1), true);
+			rte = nsitem->p_rte;
+
+			query->rtable = lappend(query->rtable, rte);
+
+			count++;
+		}
+	}
+}
+
+#define DatumGetItemPointer(X)	 ((ItemPointer) DatumGetPointer(X))
+#define PG_GETARG_ITEMPOINTER(n) DatumGetItemPointer(PG_GETARG_DATUM(n))
+
+/*
+ * ivm_visible_in_prestate
+ *
+ * Check visibility of a tuple specified by the tableoid and item pointer
+ * using the snapshot taken just before the table was modified.
+ */
+Datum
+ivm_visible_in_prestate(PG_FUNCTION_ARGS)
+{
+	Oid			tableoid = PG_GETARG_OID(0);
+	ItemPointer itemPtr = PG_GETARG_ITEMPOINTER(1);
+	Oid			matviewOid = PG_GETARG_OID(2);
+	MV_TriggerHashEntry *entry;
+	MV_TriggerTable		*table = NULL;
+	ListCell   *lc;
+	bool	found;
+	bool	result;
+
+	if (!in_delta_calculation)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ivm_visible_in_prestate can be called only in delta calculation")));
+
+	entry = (MV_TriggerHashEntry *) hash_search(mv_trigger_info,
+											  (void *) &matviewOid,
+											  HASH_FIND, &found);
+	Assert (found && entry != NULL);
+
+	foreach(lc, entry->tables)
+	{
+		table = (MV_TriggerTable *) lfirst(lc);
+		if (table->table_id == tableoid)
+			break;
+	}
+
+	Assert (table != NULL);
+
+	result = table_tuple_fetch_row_version(table->rel, itemPtr, entry->snapshot, table->slot);
+
+	PG_RETURN_BOOL(result);
+}
+
+/*
+ * get_prestate_rte
+ *
+ * Rewrite RTE of the modified table to a subquery which represents
+ * "pre-state" table. The original RTE is saved in table->rte_original.
+ */
+static RangeTblEntry*
+get_prestate_rte(RangeTblEntry *rte, MV_TriggerTable *table,
+				 QueryEnvironment *queryEnv, Oid matviewid)
+{
+	StringInfoData str;
+	RawStmt *raw;
+	Query *subquery;
+	Relation rel;
+	ParseState *pstate;
+	char *relname;
+	int i;
+
+	pstate = make_parsestate(NULL);
+	pstate->p_queryEnv = queryEnv;
+	pstate->p_expr_kind = EXPR_KIND_SELECT_TARGET;
+
+	/*
+	 * We can use NoLock here since AcquireRewriteLocks should
+	 * have locked the relation already.
+	 */
+	rel = table_open(table->table_id, NoLock);
+	relname = quote_qualified_identifier(
+					get_namespace_name(RelationGetNamespace(rel)),
+									   RelationGetRelationName(rel));
+	table_close(rel, NoLock);
+
+	/*
+	 * Filtering inserted row using the snapshot taken before the table
+	 * is modified. ctid is required for maintaining outer join views.
+	 */
+	initStringInfo(&str);
+	appendStringInfo(&str,
+		"SELECT t.* FROM %s t"
+		" WHERE pg_catalog.ivm_visible_in_prestate(t.tableoid, t.ctid ,%d::pg_catalog.oid)",
+			relname, matviewid);
+
+	/*
+	 * Append deleted rows contained in old transition tables.
+	 */
+	for (i = 0; i < list_length(table->old_tuplestores); i++)
+	{
+		appendStringInfo(&str, " UNION ALL ");
+		appendStringInfo(&str," SELECT * FROM %s",
+			make_delta_enr_name("old", table->table_id, i));
+	}
+
+	/* Get a subquery representing pre-state of the table */
+	raw = (RawStmt*)linitial(raw_parser(str.data, RAW_PARSE_DEFAULT));
+	subquery = transformStmt(pstate, raw->stmt);
+
+	/* save the original RTE */
+	table->original_rte = copyObject(rte);
+
+	rte->rtekind = RTE_SUBQUERY;
+	rte->subquery = subquery;
+	rte->security_barrier = false;
+
+	/* Clear fields that should not be set in a subquery RTE */
+	rte->relid = InvalidOid;
+	rte->relkind = 0;
+	rte->rellockmode = 0;
+	rte->tablesample = NULL;
+	rte->perminfoindex = 0;         /* no permission checking for this RTE */
+	rte->inh = false;			/* must not be set for a subquery */
+
+	return rte;
+}
+
+/*
+ * make_delta_enr_name
+ *
+ * Make a name for ENR of a transition table from the base table's oid.
+ * prefix will be "new" or "old" depending on its transition table kind..
+ */
+static char*
+make_delta_enr_name(const char *prefix, Oid relid, int count)
+{
+	char buf[NAMEDATALEN];
+	char *name;
+
+	snprintf(buf, NAMEDATALEN, "__ivm_%s_%u_%u", prefix, relid, count);
+	name = pstrdup(buf);
+
+	return name;
+}
+
+/*
+ * replace_rte_with_delta
+ *
+ * Replace RTE of the modified table with a single table delta that combine its
+ * all transition tables.
+ */
+static RangeTblEntry*
+replace_rte_with_delta(RangeTblEntry *rte, MV_TriggerTable *table, bool is_new,
+					   QueryEnvironment *queryEnv)
+{
+	Oid relid = table->table_id;
+	StringInfoData str;
+	ParseState	*pstate;
+	RawStmt *raw;
+	Query *sub;
+	int		num_tuplestores = list_length(is_new ? table->new_tuplestores : table->old_tuplestores);
+	int		i;
+
+	/* the previous RTE must be a subquery which represents "pre-state" table */
+	Assert(rte->rtekind == RTE_SUBQUERY);
+
+	/* Create a ParseState for rewriting the view definition query */
+	pstate = make_parsestate(NULL);
+	pstate->p_queryEnv = queryEnv;
+	pstate->p_expr_kind = EXPR_KIND_SELECT_TARGET;
+
+	initStringInfo(&str);
+
+	for (i = 0; i < num_tuplestores; i++)
+	{
+		if (i > 0)
+			appendStringInfo(&str, " UNION ALL ");
+
+		appendStringInfo(&str,
+			" SELECT * FROM %s",
+			make_delta_enr_name(is_new ? "new" : "old", relid, i));
+	}
+
+	raw = (RawStmt*)linitial(raw_parser(str.data, RAW_PARSE_DEFAULT));
+	sub = transformStmt(pstate, raw->stmt);
+
+	/*
+	 * Update the subquery so that it represent the combined transition
+	 * table.  Note that we leave the security_barrier and securityQuals
+	 * fields so that the subquery relation can be protected by the RLS
+	 * policy as same as the modified table.
+	 */
+	rte->rtekind = RTE_SUBQUERY;
+	rte->subquery = sub;
+
+	return rte;
+}
+
+/*
+ * rewrite_query_for_counting
+ *
+ * Rewrite query for counting duplicated tuples.
+ */
+static Query *
+rewrite_query_for_counting(Query *query, ParseState *pstate)
+{
+	TargetEntry *tle_count;
+	FuncCall *fn;
+	Node *node;
+
+	/* Add count(*) for counting distinct tuples in views */
+	fn = makeFuncCall(SystemFuncName("count"), NIL, COERCE_EXPLICIT_CALL, -1);
+	fn->agg_star = true;
+	if (!query->groupClause && !query->hasAggs)
+		query->groupClause = transformDistinctClause(NULL, &query->targetList, query->sortClause, false);
+
+	node = ParseFuncOrColumn(pstate, fn->funcname, NIL, NULL, fn, false, -1);
+
+	tle_count = makeTargetEntry((Expr *) node,
+								list_length(query->targetList) + 1,
+								pstrdup("__ivm_count__"),
+								false);
+	query->targetList = lappend(query->targetList, tle_count);
+	query->hasAggs = true;
+
+	return query;
+}
+
+/*
+ * calc_delta
+ *
+ * Calculate view deltas generated under the modification of a table specified
+ * by the RTE index.
+ */
+static void
+calc_delta(MV_TriggerTable *table, int rte_index, Query *query,
+			DestReceiver *dest_old, DestReceiver *dest_new,
+			TupleDesc *tupdesc_old, TupleDesc *tupdesc_new,
+			QueryEnvironment *queryEnv)
+{
+	ListCell *lc = list_nth_cell(query->rtable, rte_index - 1);
+	RangeTblEntry *rte = (RangeTblEntry *) lfirst(lc);
+
+	in_delta_calculation = true;
+
+	/* Generate old delta */
+	if (list_length(table->old_tuplestores) > 0)
+	{
+		/* Replace the modified table with the old delta table and calculate the old view delta. */
+		replace_rte_with_delta(rte, table, false, queryEnv);
+		refresh_matview_datafill(dest_old, query, queryEnv, tupdesc_old, "");
+	}
+
+	/* Generate new delta */
+	if (list_length(table->new_tuplestores) > 0)
+	{
+		/* Replace the modified table with the new delta table and calculate the new view delta*/
+		replace_rte_with_delta(rte, table, true, queryEnv);
+		refresh_matview_datafill(dest_new, query, queryEnv, tupdesc_new, "");
+	}
+
+	in_delta_calculation = false;
+}
+
+/*
+ * rewrite_query_for_postupdate_state
+ *
+ * Rewrite the query so that the specified base table's RTEs will represent
+ * "post-update" state of tables. This is called after the view delta
+ * calculation due to changes on this table finishes.
+ */
+static Query*
+rewrite_query_for_postupdate_state(Query *query, MV_TriggerTable *table, int rte_index)
+{
+	ListCell *lc = list_nth_cell(query->rtable, rte_index - 1);
+
+	/* Retore the original RTE */
+	lfirst(lc) = table->original_rte;
+
+	return query;
+}
+
+/*
+ * apply_delta
+ *
+ * Apply deltas to the materialized view. In outer join cases, this requires
+ * the view maintenance graph.
+ */
+static void
+apply_delta(Oid matviewOid, Tuplestorestate *old_tuplestores, Tuplestorestate *new_tuplestores,
+			TupleDesc tupdesc_old, TupleDesc tupdesc_new,
+			Query *query)
+{
+	StringInfoData querybuf;
+	StringInfoData target_list_buf;
+	Relation	matviewRel;
+	char	   *matviewname;
+	ListCell	*lc;
+	int			i;
+	List	   *keys = NIL;
+
+
+	/*
+	 * get names of the materialized view and delta tables
+	 */
+
+	matviewRel = table_open(matviewOid, NoLock);
+	matviewname = quote_qualified_identifier(get_namespace_name(RelationGetNamespace(matviewRel)),
+											 RelationGetRelationName(matviewRel));
+
+	/*
+	 * Build parts of the maintenance queries
+	 */
+
+	initStringInfo(&querybuf);
+	initStringInfo(&target_list_buf);
+
+	/* build string of target list */
+	for (i = 0; i < matviewRel->rd_att->natts; i++)
+	{
+		Form_pg_attribute attr = TupleDescAttr(matviewRel->rd_att, i);
+		char   *resname = NameStr(attr->attname);
+
+		if (i != 0)
+			appendStringInfo(&target_list_buf, ", ");
+		appendStringInfo(&target_list_buf, "%s", quote_qualified_identifier(NULL, resname));
+	}
+
+	i = 0;
+	foreach (lc, query->targetList)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+		Form_pg_attribute attr = TupleDescAttr(matviewRel->rd_att, i);
+
+		i++;
+
+		if (tle->resjunk)
+			continue;
+
+		keys = lappend(keys, attr);
+	}
+
+	/* Start maintaining the materialized view. */
+	OpenMatViewIncrementalMaintenance();
+
+	/* Open SPI context. */
+	if (SPI_connect() != SPI_OK_CONNECT)
+		elog(ERROR, "SPI_connect failed");
+
+	/* For tuple deletion */
+	if (old_tuplestores && tuplestore_tuple_count(old_tuplestores) > 0)
+	{
+		EphemeralNamedRelation enr = palloc(sizeof(EphemeralNamedRelationData));
+		int				rc;
+
+		/* convert tuplestores to ENR, and register for SPI */
+		enr->md.name = pstrdup(OLD_DELTA_ENRNAME);
+		enr->md.reliddesc = InvalidOid;
+		enr->md.tupdesc = tupdesc_old;
+		enr->md.enrtype = ENR_NAMED_TUPLESTORE;
+		enr->md.enrtuples = tuplestore_tuple_count(old_tuplestores);
+		enr->reldata = old_tuplestores;
+
+		rc = SPI_register_relation(enr);
+		if (rc != SPI_OK_REL_REGISTER)
+			elog(ERROR, "SPI_register failed");
+
+		apply_old_delta(matviewname, OLD_DELTA_ENRNAME, keys);
+
+	}
+	/* For tuple insertion */
+	if (new_tuplestores && tuplestore_tuple_count(new_tuplestores) > 0)
+	{
+		EphemeralNamedRelation enr = palloc(sizeof(EphemeralNamedRelationData));
+		int rc;
+
+		/* convert tuplestores to ENR, and register for SPI */
+		enr->md.name = pstrdup(NEW_DELTA_ENRNAME);
+		enr->md.reliddesc = InvalidOid;
+		enr->md.tupdesc = tupdesc_new;;
+		enr->md.enrtype = ENR_NAMED_TUPLESTORE;
+		enr->md.enrtuples = tuplestore_tuple_count(new_tuplestores);
+		enr->reldata = new_tuplestores;
+
+		rc = SPI_register_relation(enr);
+		if (rc != SPI_OK_REL_REGISTER)
+			elog(ERROR, "SPI_register failed");
+
+		/* apply new delta */
+		apply_new_delta(matviewname, NEW_DELTA_ENRNAME, &target_list_buf);
+	}
+
+	/* We're done maintaining the materialized view. */
+	CloseMatViewIncrementalMaintenance();
+
+	table_close(matviewRel, NoLock);
+
+	/* Close SPI context. */
+	if (SPI_finish() != SPI_OK_FINISH)
+		elog(ERROR, "SPI_finish failed");
+}
+
+/*
+ * apply_old_delta
+ *
+ * Execute a query for applying a delta table given by deltname_old
+ * which contains tuples to be deleted from to a materialized view given by
+ * matviewname.  This is used when counting is not required.
+ */
+static void
+apply_old_delta(const char *matviewname, const char *deltaname_old,
+				List *keys)
+{
+	StringInfoData	querybuf;
+	StringInfoData	keysbuf;
+	char   *match_cond;
+	ListCell *lc;
+
+	/* build WHERE condition for searching tuples to be deleted */
+	match_cond = get_matching_condition_string(keys);
+
+	/* build string of keys list */
+	initStringInfo(&keysbuf);
+	foreach (lc, keys)
+	{
+		Form_pg_attribute attr = (Form_pg_attribute) lfirst(lc);
+		char   *resname = NameStr(attr->attname);
+		appendStringInfo(&keysbuf, "%s", quote_qualified_identifier("mv", resname));
+		if (lnext(keys, lc))
+			appendStringInfo(&keysbuf, ", ");
+	}
+
+	/* Search for matching tuples from the view and update or delete if found. */
+	initStringInfo(&querybuf);
+	appendStringInfo(&querybuf,
+	"DELETE FROM %s WHERE ctid IN ("
+		"SELECT tid FROM (SELECT pg_catalog.row_number() over (partition by %s) AS \"__ivm_row_number__\","
+								  "mv.ctid AS tid,"
+								  "diff.\"__ivm_count__\""
+						 "FROM %s AS mv, %s AS diff "
+						 "WHERE %s) v "
+					"WHERE v.\"__ivm_row_number__\" OPERATOR(pg_catalog.<=) v.\"__ivm_count__\")",
+					matviewname,
+					keysbuf.data,
+					matviewname, deltaname_old,
+					match_cond);
+
+	if (SPI_exec(querybuf.data, 0) != SPI_OK_DELETE)
+		elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+}
+
+/*
+ * apply_new_delta
+ *
+ * Execute a query for applying a delta table given by deltname_new
+ * which contains tuples to be inserted into a materialized view given by
+ * matviewname.  This is used when counting is not required.
+ */
+static void
+apply_new_delta(const char *matviewname, const char *deltaname_new,
+				StringInfo target_list)
+{
+	StringInfoData	querybuf;
+
+	/* Search for matching tuples from the view and update or delete if found. */
+	initStringInfo(&querybuf);
+	appendStringInfo(&querybuf,
+					"INSERT INTO %s (%s) SELECT %s FROM ("
+						"SELECT diff.*, pg_catalog.generate_series(1, diff.\"__ivm_count__\")"
+							" AS __ivm_generate_series__ "
+						"FROM %s AS diff) AS v",
+					matviewname, target_list->data, target_list->data,
+					deltaname_new);
+
+	if (SPI_exec(querybuf.data, 0) != SPI_OK_INSERT)
+		elog(ERROR, "SPI_exec failed: %s", querybuf.data);
+}
+
+/*
+ * get_matching_condition_string
+ *
+ * Build a predicate string for looking for a tuple with given keys.
+ */
+static char *
+get_matching_condition_string(List *keys)
+{
+	StringInfoData match_cond;
+	ListCell	*lc;
+
+	/* If there is no key columns, the condition is always true. */
+	if (keys == NIL)
+		return "true";
+
+	initStringInfo(&match_cond);
+	foreach (lc, keys)
+	{
+		Form_pg_attribute attr = (Form_pg_attribute) lfirst(lc);
+		char   *resname = NameStr(attr->attname);
+		char   *mv_resname = quote_qualified_identifier("mv", resname);
+		char   *diff_resname = quote_qualified_identifier("diff", resname);
+		Oid		typid = attr->atttypid;
+
+		/* Considering NULL values, we can not use simple = operator. */
+		appendStringInfo(&match_cond, "(");
+		generate_equal(&match_cond, typid, mv_resname, diff_resname);
+		appendStringInfo(&match_cond, " OR (%s IS NULL AND %s IS NULL))",
+						 mv_resname, diff_resname);
+
+		if (lnext(keys, lc))
+			appendStringInfo(&match_cond, " AND ");
+	}
+
+	return match_cond.data;
+}
+
+/*
+ * generate_equals
+ *
+ * Generate an equality clause using given operands' default equality
+ * operator.
+ */
+static void
+generate_equal(StringInfo querybuf, Oid opttype,
+			   const char *leftop, const char *rightop)
+{
+	TypeCacheEntry *typentry;
+
+	typentry = lookup_type_cache(opttype, TYPECACHE_EQ_OPR);
+	if (!OidIsValid(typentry->eq_opr))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_FUNCTION),
+				 errmsg("could not identify an equality operator for type %s",
+						format_type_be_qualified(opttype))));
+
+	generate_operator_clause(querybuf,
+							 leftop, opttype,
+							 typentry->eq_opr,
+							 rightop, opttype);
+}
+
+/*
+ * mv_InitHashTables
+ */
+static void
+mv_InitHashTables(void)
+{
+	HASHCTL		ctl;
+
+	memset(&ctl, 0, sizeof(ctl));
+	ctl.keysize = sizeof(Oid);
+	ctl.entrysize = sizeof(MV_TriggerHashEntry);
+	mv_trigger_info = hash_create("MV trigger info",
+								 MV_INIT_QUERYHASHSIZE,
+								 &ctl, HASH_ELEM | HASH_BLOBS);
+}
+
+/*
+ * AtAbort_IVM
+ *
+ * Clean up hash entries for all materialized views. This is called at
+ * transaction abort.
+ */
+void
+AtAbort_IVM()
+{
+	HASH_SEQ_STATUS seq;
+	MV_TriggerHashEntry *entry;
+
+	if (mv_trigger_info)
+	{
+		hash_seq_init(&seq, mv_trigger_info);
+		while ((entry = hash_seq_search(&seq)) != NULL)
+			clean_up_IVM_hash_entry(entry, true);
+	}
+	in_delta_calculation = false;
+}
+
+/*
+ * clean_up_IVM_hash_entry
+ *
+ * Clean up tuple stores and hash entries for a materialized view after its
+ * maintenance finished.
+ */
+static void
+clean_up_IVM_hash_entry(MV_TriggerHashEntry *entry, bool is_abort)
+{
+	bool found;
+	ListCell *lc;
+
+	foreach(lc, entry->tables)
+	{
+		MV_TriggerTable *table = (MV_TriggerTable *) lfirst(lc);
+
+		list_free(table->old_tuplestores);
+		list_free(table->new_tuplestores);
+		if (!is_abort)
+		{
+			ExecDropSingleTupleTableSlot(table->slot);
+			table_close(table->rel, NoLock);
+		}
+	}
+	list_free(entry->tables);
+
+	if (!is_abort)
+		UnregisterSnapshot(entry->snapshot);
+
+	hash_search(mv_trigger_info, (void *) &entry->matview_id, HASH_REMOVE, &found);
+}
+
+/*
+ * isIvmName
+ *
+ * Check if this is a IVM hidden column from the name.
+ */
+bool
+isIvmName(const char *s)
+{
+	if (s)
+		return (strncmp(s, "__ivm_", 6) == 0);
+	return false;
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 134e3b22fd..8b1a87c934 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12195,4 +12195,14 @@
   proargtypes => 'int2',
   prosrc => 'gist_stratnum_identity' },
 
+# IVM
+{ oid => '786', descr => 'ivm trigger (before)',
+  proname => 'IVM_immediate_before', provolatile => 'v', prorettype => 'trigger',
+  proargtypes => '', prosrc => 'IVM_immediate_before' },
+{ oid => '787', descr => 'ivm trigger (after)',
+  proname => 'IVM_immediate_maintenance', provolatile => 'v', prorettype => 'trigger',
+  proargtypes => '', prosrc => 'IVM_immediate_maintenance' },
+{ oid => '788', descr => 'ivm filetring ',
+  proname => 'ivm_visible_in_prestate', provolatile => 's', prorettype => 'bool',
+  proargtypes => 'oid tid oid', prosrc => 'ivm_visible_in_prestate' },
 ]
diff --git a/src/include/commands/createas.h b/src/include/commands/createas.h
index 94678e3834..396ad1bb4c 100644
--- a/src/include/commands/createas.h
+++ b/src/include/commands/createas.h
@@ -16,6 +16,7 @@
 
 #include "catalog/objectaddress.h"
 #include "nodes/params.h"
+#include "nodes/pathnodes.h"
 #include "parser/parse_node.h"
 #include "tcop/dest.h"
 #include "utils/queryenvironment.h"
@@ -25,6 +26,9 @@ extern ObjectAddress ExecCreateTableAs(ParseState *pstate, CreateTableAsStmt *st
 									   ParamListInfo params, QueryEnvironment *queryEnv,
 									   QueryCompletion *qc);
 
+extern void CreateIvmTriggersOnBaseTables(Query *qry, Oid matviewOid);
+extern void CreateIndexOnIMMV(Query *query, Relation matviewRel);
+
 extern int	GetIntoRelEFlags(IntoClause *intoClause);
 
 extern DestReceiver *CreateIntoRelDestReceiver(IntoClause *intoClause);
diff --git a/src/include/commands/matview.h b/src/include/commands/matview.h
index 817b2ba0b6..3257e1adff 100644
--- a/src/include/commands/matview.h
+++ b/src/include/commands/matview.h
@@ -15,6 +15,7 @@
 #define MATVIEW_H
 
 #include "catalog/objectaddress.h"
+#include "fmgr.h"
 #include "nodes/params.h"
 #include "nodes/parsenodes.h"
 #include "tcop/dest.h"
@@ -23,6 +24,8 @@
 
 extern void SetMatViewPopulatedState(Relation relation, bool newstate);
 
+extern void SetMatViewIVMState(Relation relation, bool newstate);
+
 extern ObjectAddress ExecRefreshMatView(RefreshMatViewStmt *stmt, const char *queryString,
 										ParamListInfo params, QueryCompletion *qc);
 
@@ -30,4 +33,10 @@ extern DestReceiver *CreateTransientRelDestReceiver(Oid transientoid);
 
 extern bool MatViewIncrementalMaintenanceIsEnabled(void);
 
+extern Datum IVM_immediate_before(PG_FUNCTION_ARGS);
+extern Datum IVM_immediate_maintenance(PG_FUNCTION_ARGS);
+extern Datum IVM_visible_in_prestate(PG_FUNCTION_ARGS);
+extern void AtAbort_IVM(void);
+extern bool isIvmName(const char *s);
+
 #endif							/* MATVIEW_H */
-- 
2.25.1


--Multipart=_Sun__31_Mar_2024_22_59_31_+0900_msknEviJj08_wgqO
Content-Type: text/x-diff;
 name="v32-0007-Add-DISTINCT-support-for-IVM.patch"
Content-Disposition: attachment;
 filename="v32-0007-Add-DISTINCT-support-for-IVM.patch"
Content-Transfer-Encoding: 7bit



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v1 1/2] Support changing a column into a stored generated column
@ 2026-03-16 23:25  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v2 1/2] Support changing a column into a stored generated column
@ 2026-03-29 19:45  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--tyerjrpxgsfvwown
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v2-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v3 1/2] Support changing a column into a stored generated column
@ 2026-04-24 08:44  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

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

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


--4wx636ozsu2eakgd
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v3-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v4 1/2] Support changing a column into a stored generated column
@ 2026-05-14 21:51  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-05-14 21:51 UTC (permalink / raw)

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


--zfrmj4necy5zy3mo
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment;
	filename="v4-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"



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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v5] Support changing a column into a stored generated column
@ 2026-06-30 13:21  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-06-30 13:21 UTC (permalink / raw)

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


--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="039_stored_generated_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tests the configuration where the public=
ation is set up to publish=0A# stored generated columns.=0A#=0A# When publi=
shing stored generated columns, it is not supported for the same=0A# column=
 to be generated on both the publisher and the subscriber. The only=0A# val=
id configuration is for the column to be a regular column on the side of=0A=
# the subscriber.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause =
PostgreSQL::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=
=0A=0Amy $node_publisher =3D PostgreSQL::Test::Cluster->new('publisher');=
=0A$node_publisher->init(allows_streaming =3D> 'logical');=0A$node_publishe=
r->start;=0A=0Amy $node_subscriber =3D PostgreSQL::Test::Cluster->new('subs=
criber');=0A$node_subscriber->init;=0A$node_subscriber->start;=0A=0A# We wi=
ll use the same user throughout the test, so let's just fix it here.=0Asub =
sql=0A{=0A	local $Carp::CarpLevel =3D $Carp::CarpLevel + 1;=0A	my ($node, $=
sql_code) =3D @_;=0A	$node->safe_psql('postgres', $sql_code);=0A}=0A=0A# Sc=
hema and replication setup=0Amy $schema_ddl =3D qq[=0A	CREATE SCHEMA sch1;=
=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($node_publisher,=
 $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Amy $publisher_con=
nstr =3D $node_publisher->connstr . ' dbname=3Dpostgres';=0Asql($node_publi=
sher, qq[=0A	CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1=0A=
	WITH (publish_generated_columns =3D stored);=0A]);=0A=0Asql($node_subscrib=
er, qq[=0A	CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connst=
r'=0A		PUBLICATION tap_pub_schema;=0A]);=0A=0A$node_subscriber->wait_for_su=
bscription_sync($node_publisher, 'tap_sub_schema');=0A=0A# Initial data=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (1)]);=0A$node_publ=
isher->wait_for_catchup('tap_sub_schema');=0A=0A# Non-locking migration to =
add a stored generated column=0A# b =3D (a * 2)=0Asql($node_subscriber, qq[=
=0A	ALTER TABLE sch1.tab1 ADD COLUMN b INT;=0A]);=0Asql($node_publisher, qq=
[=0A	INSERT INTO sch1.tab1 (a) VALUES (2);=0A	ALTER TABLE sch1.tab1 ADD COL=
UMN b INT;=0A	INSERT INTO sch1.tab1 (a) VALUES (3);=0A]);=0A=0Asql($node_pu=
blisher, qq[=0A	-- Take care of new and updated rows, first.=0A	CREATE FUNC=
TION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=
=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER tr=
ig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTI=
ON sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CONSTRAINT check_gen =
CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A=0A	INSERT INTO sch1.tab1=
 (a) VALUES (4);=0A=0A	-- Now, backfill the table. In production, this migh=
t be done in batches.=0A	UPDATE sch1.tab1 SET b =3D a * 2 WHERE b IS NULL;=
=0A=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A=0A	INSERT IN=
TO sch1.tab1 (a) VALUES (5);=0A=0A	-- Now, we can convert the column withou=
t a rewrite while holding an AccessExclusiveLock.=0A	ALTER TABLE sch1.tab1 =
ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A=
=0A	INSERT INTO sch1.tab1 (a) VALUES (6);=0A]);=0A=0A$node_publisher->wait_=
for_catchup('tap_sub_schema');=0A=0Asql($node_subscriber, qq[=0A	ALTER TABL=
E sch1.tab1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) N=
OT VALID;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0As=
ql($node_publisher, qq[INSERT INTO sch1.tab1 (a) VALUES (7)]);=0A=0A$node_p=
ublisher->wait_for_catchup('tap_sub_schema');=0A=0Amy $result =3D sql($node=
_subscriber, qq[SELECT a, b FROM sch1.tab1 ORDER BY a]);=0Ais($result, qq[1=
|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12=0A7|14], 'check: fully replicated');=0A=
=0A$node_subscriber->stop('fast');=0A$node_publisher->stop('fast');=0A=0Ado=
ne_testing();=0A
--vwkr5fjnqh6lckov
Content-Type: application/x-perl
Content-Disposition: attachment;
	filename="040_stored_generated_not_published.pl"
Content-Transfer-Encoding: quoted-printable

=0A# Copyright (c) 2021-2026, PostgreSQL Global Development Group=0A=0A# Lo=
gical replication tests for=0A# ALTER COLUMN c ADD GENERATED ALWAYS STORED =
USING CONSTRAINT name=0A#=0A# This tries to exercise the replication code p=
aths when the column is=0A# converted to be a stored generated column and t=
he publication is set up to=0A# *not* publish them. The scenario is what a =
DBA would do to add a stored=0A# generated column without taking the table =
offline.=0A#=0Ause strict;=0Ause warnings FATAL =3D> 'all';=0Ause PostgreSQ=
L::Test::Cluster;=0Ause PostgreSQL::Test::Utils;=0Ause Test::More;=0A=0A# I=
nitialize publisher node=0Amy $node_publisher =3D PostgreSQL::Test::Cluster=
->new('publisher');=0A$node_publisher->init(allows_streaming =3D> 'logical'=
);=0A$node_publisher->start;=0A=0A# Create subscriber node=0Amy $node_subsc=
riber =3D PostgreSQL::Test::Cluster->new('subscriber');=0A$node_subscriber-=
>init;=0A$node_subscriber->start;=0A=0A# We will use the same user througho=
ut the test, so let's just fix it here.=0Asub sql=0A{=0A	local $Carp::CarpL=
evel =3D $Carp::CarpLevel + 1;=0A	my ($node, $sql_code) =3D @_;=0A	$node->s=
afe_psql('postgres', $sql_code);=0A}=0A=0Amy $schema_ddl =3D qq[=0A	CREATE =
SCHEMA sch1;=0A	CREATE TABLE sch1.tab1 (a INT PRIMARY KEY);=0A];=0Asql($nod=
e_publisher, $schema_ddl);=0Asql($node_subscriber, $schema_ddl);=0A=0Asql($=
node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (1);=0A]);=0A=0A# S=
et up replication=0Amy $publisher_connstr =3D $node_publisher->connstr . ' =
dbname=3Dpostgres';=0Asql($node_publisher, qq[=0A	CREATE PUBLICATION tap_pu=
b_schema FOR TABLES IN SCHEMA sch1=0A	WITH (publish_generated_columns =3D n=
one)=0A]);=0Asql($node_subscriber, qq[=0A	CREATE SUBSCRIPTION tap_sub_schem=
a CONNECTION '$publisher_connstr'=0A	    PUBLICATION tap_pub_schema=0A]);=
=0A$node_subscriber->wait_for_subscription_sync($node_publisher,=0A	'tap_su=
b_schema');=0A=0Amy $result =3D sql($node_subscriber, "SELECT a FROM sch1.t=
ab1");=0Ais($result, "1",=0A	'sanity check: initial data has been synced');=
=0A=0A# Now we want to add a stored generated column `b`. Since we are not =
set up=0A# to publish stored generated column, we need to set up the subscr=
iber first.=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUMN=
 b INT;=0A]);=0Asql($node_publisher, qq[=0A	ALTER TABLE sch1.tab1 ADD COLUM=
N b INT;=0A]);=0A=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) v=
alues (2);=0A]);=0A=0A# Take care of new/updated rows=0Asql($node_publisher=
, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS TRIGGER LANGUAGE plpgsq=
l AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN NEW;=0A	END=0A	\$\$;=0A=
	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON sch1.tab1=0A		FOR EACH =
ROW EXECUTE FUNCTION sch1.generate_b();=0A=0A	ALTER TABLE sch1.tab1 ADD CON=
STRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=0A]);=0A=
=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (3);=0A]);=
=0A=0A# Backfill=0Asql($node_publisher, qq[=0A	UPDATE sch1.tab1 SET b =3D a=
 * 2 WHERE b IS NULL;=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_ge=
n;=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=0A=0A# When=
 we switch "b" to a stored gen column on the publisher, it will not be=0A# =
synced anymore. Let's set up the replica first, in order to not lose data.=
=0Asql($node_subscriber, qq[=0A	CREATE FUNCTION sch1.generate_b () RETURNS =
TRIGGER LANGUAGE plpgsql AS \$\$=0A	BEGIN=0A	  NEW.b =3D NEW.a * 2; RETURN =
NEW;=0A	END=0A	\$\$;=0A	CREATE TRIGGER trig_gen BEFORE INSERT OR UPDATE ON =
sch1.tab1=0A		FOR EACH ROW EXECUTE FUNCTION sch1.generate_b();=0A    ALTER =
TABLE sch1.tab1 ENABLE REPLICA TRIGGER trig_gen;=0A=0A	ALTER TABLE sch1.tab=
1 ADD CONSTRAINT check_gen CHECK (b IS NOT DISTINCT FROM a * 2) NOT VALID;=
=0A	ALTER TABLE sch1.tab1 VALIDATE CONSTRAINT check_gen;=0A]);=0A=0Asql($no=
de_publisher, qq[=0A	INSERT INTO sch1.tab1 (a) values (4);=0A]);=0A=0A# Con=
vert b to a stored generated column on the publisher first: b will not=0A# =
be synced anymore, but the trigger and constraint on the subscriber guarant=
ee=0A# that it writes the correct values.=0Asql($node_publisher, qq[=0A	ALT=
ER TABLE sch1.tab1 ALTER COLUMN b ADD GENERATED ALWAYS STORED USING CONSTRA=
INT check_gen;=0A]);=0Asql($node_publisher, qq[=0A	INSERT INTO sch1.tab1 (a=
) values (5);=0A]);=0A$node_publisher->wait_for_catchup('tap_sub_schema');=
=0Asql($node_subscriber, qq[=0A	ALTER TABLE sch1.tab1 ALTER COLUMN b ADD GE=
NERATED ALWAYS STORED USING CONSTRAINT check_gen;=0A]);=0A=0Asql($node_publ=
isher, qq[=0A	INSERT INTO sch1.tab1 (a) values (6);=0A]);=0A=0A$node_publis=
her->wait_for_catchup('tap_sub_schema');=0A=0A# Check: all the rows have th=
e correct values in b=0A$result =3D sql($node_subscriber, "SELECT * FROM sc=
h1.tab1 ORDER BY a");=0Ais($result, qq[1|2=0A2|4=0A3|6=0A4|8=0A5|10=0A6|12]=
, 'check: all of "b" has been replicated');=0A=0A$node_subscriber->stop('fa=
st');=0A$node_publisher->stop('fast');=0A=0Adone_testing();=0A
--vwkr5fjnqh6lckov--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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

* [PATCH v6] Support changing a column into a stored generated column
@ 2026-07-03 05:52  Alberto Piai <[email protected]>
  0 siblings, 0 replies; 162+ messages in thread

From: Alberto Piai @ 2026-07-03 05:52 UTC (permalink / raw)

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


--vszqhseuea6ckzmb--





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


end of thread, other threads:[~2026-07-03 05:52 UTC | newest]

Thread overview: 162+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2023-05-31 09:59 [PATCH v32 06/11] Add Incremental View Maintenance support Yugo Nagata <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox