agora inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v17 5/8] Row pattern recognition patch (executor).
234+ messages / 2 participants
[nested] [flat]

* [PATCH v17 5/8] Row pattern recognition patch (executor).
@ 2024-04-28 11:00 Tatsuo Ishii <[email protected]>
  0 siblings, 0 replies; 234+ messages in thread

From: Tatsuo Ishii @ 2024-04-28 11:00 UTC (permalink / raw)

---
 src/backend/executor/nodeWindowAgg.c | 1610 +++++++++++++++++++++++++-
 src/backend/utils/adt/windowfuncs.c  |   37 +-
 src/include/catalog/pg_proc.dat      |    6 +
 src/include/nodes/execnodes.h        |   30 +
 4 files changed, 1671 insertions(+), 12 deletions(-)

diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c
index 3221fa1522..140bb3941e 100644
--- a/src/backend/executor/nodeWindowAgg.c
+++ b/src/backend/executor/nodeWindowAgg.c
@@ -36,6 +36,7 @@
 #include "access/htup_details.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_aggregate.h"
+#include "catalog/pg_collation_d.h"
 #include "catalog/pg_proc.h"
 #include "executor/executor.h"
 #include "executor/nodeWindowAgg.h"
@@ -48,6 +49,7 @@
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/datum.h"
+#include "utils/fmgroids.h"
 #include "utils/expandeddatum.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
@@ -159,6 +161,43 @@ typedef struct WindowStatePerAggData
 	bool		restart;		/* need to restart this agg in this cycle? */
 } WindowStatePerAggData;
 
+/*
+ * Set of StringInfo. Used in RPR.
+ */
+typedef struct StringSet
+{
+	StringInfo *str_set;
+	Size		set_size;		/* current array allocation size in number of
+								 * items */
+	int			set_index;		/* current used size */
+}			StringSet;
+
+/*
+ * Allowed subsequent PATTERN variables positions.
+ * Used in RPR.
+ *
+ * pos represents the pattern variable defined order in DEFINE caluase.  For
+ * example. "DEFINE START..., UP..., DOWN ..." and "PATTERN START UP DOWN UP"
+ * will create:
+ * VariablePos[0].pos[0] = 0;		START
+ * VariablePos[1].pos[0] = 1;		UP
+ * VariablePos[1].pos[1] = 3;		UP
+ * VariablePos[2].pos[0] = 2;		DOWN
+ *
+ * Note that UP has two pos because UP appears in PATTERN twice.
+ *
+ * By using this strucrture, we can know which pattern variable can be followed
+ * by which pattern variable(s). For example, START can be followed by UP and
+ * DOWN since START's pos is 0, and UP's pos is 1 or 3, DOWN's pos is 2.
+ * DOWN can be followed by UP since UP's pos is either 1 or 3.
+ *
+ */
+#define NUM_ALPHABETS	26		/* we allow [a-z] variable initials */
+typedef struct VariablePos
+{
+	int			pos[NUM_ALPHABETS]; /* postion(s) in PATTERN */
+}			VariablePos;
+
 static void initialize_windowaggregate(WindowAggState *winstate,
 									   WindowStatePerFunc perfuncstate,
 									   WindowStatePerAgg peraggstate);
@@ -184,6 +223,7 @@ static void release_partition(WindowAggState *winstate);
 
 static int	row_is_in_frame(WindowAggState *winstate, int64 pos,
 							TupleTableSlot *slot);
+
 static void update_frameheadpos(WindowAggState *winstate);
 static void update_frametailpos(WindowAggState *winstate);
 static void update_grouptailpos(WindowAggState *winstate);
@@ -195,9 +235,48 @@ static Datum GetAggInitVal(Datum textInitVal, Oid transtype);
 
 static bool are_peers(WindowAggState *winstate, TupleTableSlot *slot1,
 					  TupleTableSlot *slot2);
+
+static int	WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+							  int relpos, int seektype, bool set_mark,
+							  bool *isnull, bool *isout);
 static bool window_gettupleslot(WindowObject winobj, int64 pos,
 								TupleTableSlot *slot);
 
+static void attno_map(Node *node);
+static bool attno_map_walker(Node *node, void *context);
+static int	row_is_in_reduced_frame(WindowObject winobj, int64 pos);
+static bool rpr_is_defined(WindowAggState *winstate);
+
+static void create_reduced_frame_map(WindowAggState *winstate);
+static int	get_reduced_frame_map(WindowAggState *winstate, int64 pos);
+static void register_reduced_frame_map(WindowAggState *winstate, int64 pos,
+									   int val);
+static void clear_reduced_frame_map(WindowAggState *winstate);
+static void update_reduced_frame(WindowObject winobj, int64 pos);
+
+static int64 evaluate_pattern(WindowObject winobj, int64 current_pos,
+							  char *vname, StringInfo encoded_str, bool *result);
+
+static bool get_slots(WindowObject winobj, int64 current_pos);
+
+static int	search_str_set(char *pattern, StringSet * str_set,
+						   VariablePos * variable_pos);
+static char pattern_initial(WindowAggState *winstate, char *vname);
+static int	do_pattern_match(char *pattern, char *encoded_str);
+
+static StringSet * string_set_init(void);
+static void string_set_add(StringSet * string_set, StringInfo str);
+static StringInfo string_set_get(StringSet * string_set, int index);
+static int	string_set_get_size(StringSet * string_set);
+static void string_set_discard(StringSet * string_set);
+static VariablePos * variable_pos_init(void);
+static void variable_pos_register(VariablePos * variable_pos, char initial,
+								  int pos);
+static bool variable_pos_compare(VariablePos * variable_pos,
+								 char initial1, char initial2);
+static int	variable_pos_fetch(VariablePos * variable_pos, char initial,
+							   int index);
+static void variable_pos_discard(VariablePos * variable_pos);
 
 /*
  * initialize_windowaggregate
@@ -774,10 +853,12 @@ eval_windowaggregates(WindowAggState *winstate)
 	 *	   transition function, or
 	 *	 - we have an EXCLUSION clause, or
 	 *	 - if the new frame doesn't overlap the old one
+	 *   - if RPR is enabled
 	 *
 	 * Note that we don't strictly need to restart in the last case, but if
 	 * we're going to remove all rows from the aggregation anyway, a restart
 	 * surely is faster.
+	 *     we restart aggregation too.
 	 *----------
 	 */
 	numaggs_restart = 0;
@@ -788,7 +869,8 @@ eval_windowaggregates(WindowAggState *winstate)
 			(winstate->aggregatedbase != winstate->frameheadpos &&
 			 !OidIsValid(peraggstate->invtransfn_oid)) ||
 			(winstate->frameOptions & FRAMEOPTION_EXCLUSION) ||
-			winstate->aggregatedupto <= winstate->frameheadpos)
+			winstate->aggregatedupto <= winstate->frameheadpos ||
+			rpr_is_defined(winstate))
 		{
 			peraggstate->restart = true;
 			numaggs_restart++;
@@ -862,7 +944,22 @@ eval_windowaggregates(WindowAggState *winstate)
 	 * head, so that tuplestore can discard unnecessary rows.
 	 */
 	if (agg_winobj->markptr >= 0)
-		WinSetMarkPosition(agg_winobj, winstate->frameheadpos);
+	{
+		int64		markpos = winstate->frameheadpos;
+
+		if (rpr_is_defined(winstate))
+		{
+			/*
+			 * If RPR is used, it is possible PREV wants to look at the
+			 * previous row.  So the mark pos should be frameheadpos - 1
+			 * unless it is below 0.
+			 */
+			markpos -= 1;
+			if (markpos < 0)
+				markpos = 0;
+		}
+		WinSetMarkPosition(agg_winobj, markpos);
+	}
 
 	/*
 	 * Now restart the aggregates that require it.
@@ -917,6 +1014,14 @@ eval_windowaggregates(WindowAggState *winstate)
 	{
 		winstate->aggregatedupto = winstate->frameheadpos;
 		ExecClearTuple(agg_row_slot);
+
+		/*
+		 * If RPR is defined, we do not use aggregatedupto_nonrestarted.  To
+		 * avoid assertion failure below, we reset aggregatedupto_nonrestarted
+		 * to frameheadpos.
+		 */
+		if (rpr_is_defined(winstate))
+			aggregatedupto_nonrestarted = winstate->frameheadpos;
 	}
 
 	/*
@@ -930,6 +1035,12 @@ eval_windowaggregates(WindowAggState *winstate)
 	{
 		int			ret;
 
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "===== loop in frame starts: aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT,
+			 winstate->aggregatedupto,
+			 winstate->aggregatedbase);
+#endif
+
 		/* Fetch next row if we didn't already */
 		if (TupIsNull(agg_row_slot))
 		{
@@ -945,9 +1056,52 @@ eval_windowaggregates(WindowAggState *winstate)
 		ret = row_is_in_frame(winstate, winstate->aggregatedupto, agg_row_slot);
 		if (ret < 0)
 			break;
+
 		if (ret == 0)
 			goto next_tuple;
 
+		if (rpr_is_defined(winstate))
+		{
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "reduced_frame_map: %d aggregatedupto: " INT64_FORMAT " aggregatedbase: " INT64_FORMAT,
+				 get_reduced_frame_map(winstate,
+									   winstate->aggregatedupto),
+				 winstate->aggregatedupto,
+				 winstate->aggregatedbase);
+#endif
+			/*
+			 * If the row status at currentpos is already decided and current
+			 * row status is not decided yet, it means we passed the last
+			 * reduced frame. Time to break the loop.
+			 */
+			if (get_reduced_frame_map(winstate,
+									  winstate->currentpos) != RF_NOT_DETERMINED &&
+				get_reduced_frame_map(winstate,
+									  winstate->aggregatedupto) == RF_NOT_DETERMINED)
+				break;
+
+			/*
+			 * Otherwise we need to calculate the reduced frame.
+			 */
+			ret = row_is_in_reduced_frame(winstate->agg_winobj,
+										  winstate->aggregatedupto);
+			if (ret == -1)		/* unmatched row */
+				break;
+
+			/*
+			 * Check if current row needs to be skipped due to no match.
+			 */
+			if (get_reduced_frame_map(winstate,
+									  winstate->aggregatedupto) == RF_SKIPPED &&
+				winstate->aggregatedupto == winstate->aggregatedbase)
+			{
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "skip current row for aggregation");
+#endif
+				break;
+			}
+		}
+
 		/* Set tuple context for evaluation of aggregate arguments */
 		winstate->tmpcontext->ecxt_outertuple = agg_row_slot;
 
@@ -976,6 +1130,7 @@ next_tuple:
 		ExecClearTuple(agg_row_slot);
 	}
 
+
 	/* The frame's end is not supposed to move backwards, ever */
 	Assert(aggregatedupto_nonrestarted <= winstate->aggregatedupto);
 
@@ -995,7 +1150,6 @@ next_tuple:
 								 &winstate->perfunc[wfuncno],
 								 peraggstate,
 								 result, isnull);
-
 		/*
 		 * save the result in case next row shares the same frame.
 		 *
@@ -1090,6 +1244,7 @@ begin_partition(WindowAggState *winstate)
 	winstate->framehead_valid = false;
 	winstate->frametail_valid = false;
 	winstate->grouptail_valid = false;
+	create_reduced_frame_map(winstate);
 	winstate->spooled_rows = 0;
 	winstate->currentpos = 0;
 	winstate->frameheadpos = 0;
@@ -2053,6 +2208,11 @@ ExecWindowAgg(PlanState *pstate)
 
 	CHECK_FOR_INTERRUPTS();
 
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "ExecWindowAgg called. pos: " INT64_FORMAT,
+		 winstate->currentpos);
+#endif
+
 	if (winstate->status == WINDOWAGG_DONE)
 		return NULL;
 
@@ -2221,6 +2381,17 @@ ExecWindowAgg(PlanState *pstate)
 		/* don't evaluate the window functions when we're in pass-through mode */
 		if (winstate->status == WINDOWAGG_RUN)
 		{
+			/*
+			 * If RPR is defined and skip mode is next row, we need to clear
+			 * existing reduced frame info so that we newly calculate the info
+			 * starting from current row.
+			 */
+			if (rpr_is_defined(winstate))
+			{
+				if (winstate->rpSkipTo == ST_NEXT_ROW)
+					clear_reduced_frame_map(winstate);
+			}
+
 			/*
 			 * Evaluate true window functions
 			 */
@@ -2388,6 +2559,9 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	TupleDesc	scanDesc;
 	ListCell   *l;
 
+	TargetEntry *te;
+	Expr	   *expr;
+
 	/* check for unsupported flags */
 	Assert(!(eflags & (EXEC_FLAG_BACKWARD | EXEC_FLAG_MARK)));
 
@@ -2486,6 +2660,16 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->temp_slot_2 = ExecInitExtraTupleSlot(estate, scanDesc,
 												   &TTSOpsMinimalTuple);
 
+	winstate->prev_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+
+	winstate->next_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+
+	winstate->null_slot = ExecInitExtraTupleSlot(estate, scanDesc,
+												 &TTSOpsMinimalTuple);
+	winstate->null_slot = ExecStoreAllNullTuple(winstate->null_slot);
+
 	/*
 	 * create frame head and tail slots only if needed (must create slots in
 	 * exactly the same cases that update_frameheadpos and update_frametailpos
@@ -2667,6 +2851,43 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	winstate->inRangeAsc = node->inRangeAsc;
 	winstate->inRangeNullsFirst = node->inRangeNullsFirst;
 
+	/* Set up SKIP TO type */
+	winstate->rpSkipTo = node->rpSkipTo;
+	/* Set up row pattern recognition PATTERN clause */
+	winstate->patternVariableList = node->patternVariable;
+	winstate->patternRegexpList = node->patternRegexp;
+
+	/* Set up row pattern recognition DEFINE clause */
+	winstate->defineInitial = node->defineInitial;
+	winstate->defineVariableList = NIL;
+	winstate->defineClauseList = NIL;
+	if (node->defineClause != NIL)
+	{
+		/*
+		 * Tweak arg var of PREV/NEXT so that it refers to scan/inner slot.
+		 */
+		foreach(l, node->defineClause)
+		{
+			char	   *name;
+			ExprState  *exps;
+
+			te = lfirst(l);
+			name = te->resname;
+			expr = te->expr;
+
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "defineVariable name: %s", name);
+#endif
+			winstate->defineVariableList =
+				lappend(winstate->defineVariableList,
+						makeString(pstrdup(name)));
+			attno_map((Node *) expr);
+			exps = ExecInitExpr(expr, (PlanState *) winstate);
+			winstate->defineClauseList =
+				lappend(winstate->defineClauseList, exps);
+		}
+	}
+
 	winstate->all_first = true;
 	winstate->partition_spooled = false;
 	winstate->more_partitions = false;
@@ -2674,6 +2895,64 @@ ExecInitWindowAgg(WindowAgg *node, EState *estate, int eflags)
 	return winstate;
 }
 
+/*
+ * Rewrite varno of Var node that is the argument of PREV/NET so that it sees
+ * scan tuple (PREV) or inner tuple (NEXT).
+ */
+static void
+attno_map(Node *node)
+{
+	(void) expression_tree_walker(node, attno_map_walker, NULL);
+}
+
+static bool
+attno_map_walker(Node *node, void *context)
+{
+	FuncExpr   *func;
+	int			nargs;
+	Expr	   *expr;
+	Var		   *var;
+
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, FuncExpr))
+	{
+		func = (FuncExpr *) node;
+
+		if (func->funcid == F_PREV || func->funcid == F_NEXT)
+		{
+			/* sanity check */
+			nargs = list_length(func->args);
+			if (list_length(func->args) != 1)
+				elog(ERROR, "PREV/NEXT must have 1 argument but function %d has %d args",
+					 func->funcid, nargs);
+
+			expr = (Expr *) lfirst(list_head(func->args));
+			if (!IsA(expr, Var))
+				elog(ERROR, "PREV/NEXT's arg is not Var");	/* XXX: is it possible
+															 * that arg type is
+															 * Const? */
+			var = (Var *) expr;
+
+			if (func->funcid == F_PREV)
+
+				/*
+				 * Rewrite varno from OUTER_VAR to regular var no so that the
+				 * var references scan tuple.
+				 */
+				var->varno = var->varnosyn;
+			else
+				var->varno = INNER_VAR;
+
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "PREV/NEXT's varno is rewritten to: %d", var->varno);
+#endif
+		}
+	}
+	return expression_tree_walker(node, attno_map_walker, NULL);
+}
+
 /* -----------------
  * ExecEndWindowAgg
  * -----------------
@@ -2723,6 +3002,8 @@ ExecReScanWindowAgg(WindowAggState *node)
 	ExecClearTuple(node->agg_row_slot);
 	ExecClearTuple(node->temp_slot_1);
 	ExecClearTuple(node->temp_slot_2);
+	ExecClearTuple(node->prev_slot);
+	ExecClearTuple(node->next_slot);
 	if (node->framehead_slot)
 		ExecClearTuple(node->framehead_slot);
 	if (node->frametail_slot)
@@ -3083,7 +3364,8 @@ window_gettupleslot(WindowObject winobj, int64 pos, TupleTableSlot *slot)
 		return false;
 
 	if (pos < winobj->markpos)
-		elog(ERROR, "cannot fetch row before WindowObject's mark position");
+		elog(ERROR, "cannot fetch row: " INT64_FORMAT " before WindowObject's mark position: " INT64_FORMAT,
+			 pos, winobj->markpos);
 
 	oldcontext = MemoryContextSwitchTo(winstate->ss.ps.ps_ExprContext->ecxt_per_query_memory);
 
@@ -3403,14 +3685,54 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 	WindowAggState *winstate;
 	ExprContext *econtext;
 	TupleTableSlot *slot;
-	int64		abs_pos;
-	int64		mark_pos;
 
 	Assert(WindowObjectIsValid(winobj));
 	winstate = winobj->winstate;
 	econtext = winstate->ss.ps.ps_ExprContext;
 	slot = winstate->temp_slot_1;
 
+	if (WinGetSlotInFrame(winobj, slot,
+						  relpos, seektype, set_mark,
+						  isnull, isout) == 0)
+	{
+		econtext->ecxt_outertuple = slot;
+		return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
+							econtext, isnull);
+	}
+
+	if (isout)
+		*isout = true;
+	*isnull = true;
+	return (Datum) 0;
+}
+
+/*
+ * WinGetSlotInFrame
+ * slot: TupleTableSlot to store the result
+ * relpos: signed rowcount offset from the seek position
+ * seektype: WINDOW_SEEK_HEAD or WINDOW_SEEK_TAIL
+ * set_mark: If the row is found/in frame and set_mark is true, the mark is
+ *		moved to the row as a side-effect.
+ * isnull: output argument, receives isnull status of result
+ * isout: output argument, set to indicate whether target row position
+ *		is out of frame (can pass NULL if caller doesn't care about this)
+ *
+ * Returns 0 if we successfullt got the slot. false if out of frame.
+ * (also isout is set)
+ */
+static int
+WinGetSlotInFrame(WindowObject winobj, TupleTableSlot *slot,
+				  int relpos, int seektype, bool set_mark,
+				  bool *isnull, bool *isout)
+{
+	WindowAggState *winstate;
+	int64		abs_pos;
+	int64		mark_pos;
+	int			num_reduced_frame;
+
+	Assert(WindowObjectIsValid(winobj));
+	winstate = winobj->winstate;
+
 	switch (seektype)
 	{
 		case WINDOW_SEEK_CURRENT:
@@ -3477,11 +3799,25 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 						 winstate->frameOptions);
 					break;
 			}
+			num_reduced_frame = row_is_in_reduced_frame(winobj,
+														winstate->frameheadpos);
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+				if (relpos >= num_reduced_frame)
+					goto out_of_frame;
 			break;
 		case WINDOW_SEEK_TAIL:
 			/* rejecting relpos > 0 is easy and simplifies code below */
 			if (relpos > 0)
 				goto out_of_frame;
+
+			/*
+			 * RPR cares about frame head pos. Need to call
+			 * update_frameheadpos
+			 */
+			update_frameheadpos(winstate);
+
 			update_frametailpos(winstate);
 			abs_pos = winstate->frametailpos - 1 + relpos;
 
@@ -3548,6 +3884,14 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 					mark_pos = 0;	/* keep compiler quiet */
 					break;
 			}
+
+			num_reduced_frame = row_is_in_reduced_frame(winobj,
+														winstate->frameheadpos + relpos);
+			if (num_reduced_frame < 0)
+				goto out_of_frame;
+			else if (num_reduced_frame > 0)
+				abs_pos = winstate->frameheadpos + relpos +
+					num_reduced_frame - 1;
 			break;
 		default:
 			elog(ERROR, "unrecognized window seek type: %d", seektype);
@@ -3566,15 +3910,13 @@ WinGetFuncArgInFrame(WindowObject winobj, int argno,
 		*isout = false;
 	if (set_mark)
 		WinSetMarkPosition(winobj, mark_pos);
-	econtext->ecxt_outertuple = slot;
-	return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
-						econtext, isnull);
+	return 0;
 
 out_of_frame:
 	if (isout)
 		*isout = true;
 	*isnull = true;
-	return (Datum) 0;
+	return -1;
 }
 
 /*
@@ -3605,3 +3947,1251 @@ WinGetFuncArgCurrent(WindowObject winobj, int argno, bool *isnull)
 	return ExecEvalExpr((ExprState *) list_nth(winobj->argstates, argno),
 						econtext, isnull);
 }
+
+/*
+ * rpr_is_defined
+ * return true if Row pattern recognition is defined.
+ */
+static
+bool
+rpr_is_defined(WindowAggState *winstate)
+{
+	return winstate->patternVariableList != NIL;
+}
+
+/*
+ * -----------------
+ * row_is_in_reduced_frame
+ * Determine whether a row is in the current row's reduced window frame
+ * according to row pattern matching
+ *
+ * The row must has been already determined that it is in a full window frame
+ * and fetched it into slot.
+ *
+ * Returns:
+ * = 0, RPR is not defined.
+ * >0, if the row is the first in the reduced frame. Return the number of rows
+ * in the reduced frame.
+ * -1, if the row is unmatched row
+ * -2, if the row is in the reduced frame but needed to be skipped because of
+ * AFTER MATCH SKIP PAST LAST ROW
+ * -----------------
+ */
+static
+int
+row_is_in_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	int			state;
+	int			rtn;
+
+	if (!rpr_is_defined(winstate))
+	{
+		/*
+		 * RPR is not defined. Assume that we are always in the the reduced
+		 * window frame.
+		 */
+		rtn = 0;
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT,
+			 rtn, pos);
+#endif
+		return rtn;
+	}
+
+	state = get_reduced_frame_map(winstate, pos);
+
+	if (state == RF_NOT_DETERMINED)
+	{
+		update_frameheadpos(winstate);
+		update_reduced_frame(winobj, pos);
+	}
+
+	state = get_reduced_frame_map(winstate, pos);
+
+	switch (state)
+	{
+			int64		i;
+			int			num_reduced_rows;
+
+		case RF_FRAME_HEAD:
+			num_reduced_rows = 1;
+			for (i = pos + 1;
+				 get_reduced_frame_map(winstate, i) == RF_SKIPPED; i++)
+				num_reduced_rows++;
+			rtn = num_reduced_rows;
+			break;
+
+		case RF_SKIPPED:
+			rtn = -2;
+			break;
+
+		case RF_UNMATCHED:
+			rtn = -1;
+			break;
+
+		default:
+			elog(ERROR, "Unrecognized state: %d at: " INT64_FORMAT,
+				 state, pos);
+			break;
+	}
+
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "row_is_in_reduced_frame returns %d: pos: " INT64_FORMAT,
+		 rtn, pos);
+#endif
+	return rtn;
+}
+
+#define REDUCED_FRAME_MAP_INIT_SIZE	1024L
+
+/*
+ * create_reduced_frame_map
+ * Create reduced frame map
+ */
+static
+void
+create_reduced_frame_map(WindowAggState *winstate)
+{
+	winstate->reduced_frame_map =
+		MemoryContextAlloc(winstate->partcontext,
+						   REDUCED_FRAME_MAP_INIT_SIZE);
+	winstate->alloc_sz = REDUCED_FRAME_MAP_INIT_SIZE;
+	clear_reduced_frame_map(winstate);
+}
+
+/*
+ * clear_reduced_frame_map
+ * Clear reduced frame map
+ */
+static
+void
+clear_reduced_frame_map(WindowAggState *winstate)
+{
+	Assert(winstate->reduced_frame_map != NULL);
+	MemSet(winstate->reduced_frame_map, RF_NOT_DETERMINED,
+		   winstate->alloc_sz);
+}
+
+/*
+ * get_reduced_frame_map
+ * Get reduced frame map specified by pos
+ */
+static
+int
+get_reduced_frame_map(WindowAggState *winstate, int64 pos)
+{
+	Assert(winstate->reduced_frame_map != NULL);
+
+	if (pos < 0 || pos >= winstate->alloc_sz)
+		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
+
+	return winstate->reduced_frame_map[pos];
+}
+
+/*
+ * register_reduced_frame_map
+ * Add/replace reduced frame map member at pos.
+ * If there's no enough space, expand the map.
+ */
+static
+void
+register_reduced_frame_map(WindowAggState *winstate, int64 pos, int val)
+{
+	int64		realloc_sz;
+
+	Assert(winstate->reduced_frame_map != NULL);
+
+	if (pos < 0)
+		elog(ERROR, "wrong pos: " INT64_FORMAT, pos);
+
+	if (pos > winstate->alloc_sz - 1)
+	{
+		realloc_sz = winstate->alloc_sz * 2;
+
+		winstate->reduced_frame_map =
+			repalloc(winstate->reduced_frame_map, realloc_sz);
+
+		MemSet(winstate->reduced_frame_map + winstate->alloc_sz,
+			   RF_NOT_DETERMINED, realloc_sz - winstate->alloc_sz);
+
+		winstate->alloc_sz = realloc_sz;
+	}
+
+	winstate->reduced_frame_map[pos] = val;
+}
+
+/*
+ * update_reduced_frame
+ *		Update reduced frame info.
+ */
+static
+void
+update_reduced_frame(WindowObject winobj, int64 pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	ListCell   *lc1,
+			   *lc2;
+	bool		expression_result;
+	int			num_matched_rows;
+	int64		original_pos;
+	bool		anymatch;
+	StringInfo	encoded_str;
+	StringInfo	pattern_str = makeStringInfo();
+	StringSet  *str_set;
+	int			initial_index;
+	VariablePos *variable_pos;
+	bool		greedy = false;
+	int64		result_pos,
+				i;
+
+	/*
+	 * Set of pattern variables evaluated to true. Each character corresponds
+	 * to pattern variable. Example: str_set[0] = "AB"; str_set[1] = "AC"; In
+	 * this case at row 0 A and B are true, and A and C are true in row 1.
+	 */
+
+	/* initialize pattern variables set */
+	str_set = string_set_init();
+
+	/* save original pos */
+	original_pos = pos;
+
+	/*
+	 * Check if the pattern does not include any greedy quantifier. If it does
+	 * not, we can just apply the pattern to each row. If it succeeds, we are
+	 * done.
+	 */
+	foreach(lc1, winstate->patternRegexpList)
+	{
+		char	   *quantifier = strVal(lfirst(lc1));
+
+		if (*quantifier == '+' || *quantifier == '*')
+		{
+			greedy = true;
+			break;
+		}
+	}
+
+	/*
+	 * Non greedy case
+	 */
+	if (!greedy)
+	{
+		num_matched_rows = 0;
+
+		foreach(lc1, winstate->patternVariableList)
+		{
+			char	   *vname = strVal(lfirst(lc1));
+
+			encoded_str = makeStringInfo();
+
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "pos: " INT64_FORMAT " pattern vname: %s",
+				 pos, vname);
+#endif
+			expression_result = false;
+
+			/* evaluate row pattern against current row */
+			result_pos = evaluate_pattern(winobj, pos, vname,
+										  encoded_str, &expression_result);
+			if (!expression_result || result_pos < 0)
+			{
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "expression result is false or out of frame");
+#endif
+				register_reduced_frame_map(winstate, original_pos,
+										   RF_UNMATCHED);
+				return;
+			}
+			/* move to next row */
+			pos++;
+			num_matched_rows++;
+		}
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "pattern matched");
+#endif
+		register_reduced_frame_map(winstate, original_pos, RF_FRAME_HEAD);
+
+		for (i = original_pos + 1; i < original_pos + num_matched_rows; i++)
+		{
+			register_reduced_frame_map(winstate, i, RF_SKIPPED);
+		}
+		return;
+	}
+
+	/*
+	 * Greedy quantifiers included. Loop over until none of pattern matches or
+	 * encounters end of frame.
+	 */
+	for (;;)
+	{
+		result_pos = -1;
+
+		/*
+		 * Loop over each PATTERN variable.
+		 */
+		anymatch = false;
+		encoded_str = makeStringInfo();
+
+		forboth(lc1, winstate->patternVariableList, lc2,
+				winstate->patternRegexpList)
+		{
+			char	   *vname = strVal(lfirst(lc1));
+#ifdef RPR_DEBUG
+			char	   *quantifier = strVal(lfirst(lc2));
+
+			elog(DEBUG1, "pos: " INT64_FORMAT " pattern vname: %s quantifier: %s",
+				 pos, vname, quantifier);
+#endif
+			expression_result = false;
+
+			/* evaluate row pattern against current row */
+			result_pos = evaluate_pattern(winobj, pos, vname,
+										  encoded_str, &expression_result);
+			if (expression_result)
+			{
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "expression result is true");
+#endif
+				anymatch = true;
+			}
+
+			/*
+			 * If out of frame, we are done.
+			 */
+			if (result_pos < 0)
+				break;
+		}
+
+		if (!anymatch)
+		{
+			/* none of patterns matched. */
+			break;
+		}
+
+		string_set_add(str_set, encoded_str);
+
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "pos: " INT64_FORMAT " encoded_str: %s",
+			 encoded_str->data);
+#endif
+
+		/* move to next row */
+		pos++;
+
+		if (result_pos < 0)
+		{
+			/* out of frame */
+			break;
+		}
+	}
+
+	if (string_set_get_size(str_set) == 0)
+	{
+		/* no match found in the first row */
+		register_reduced_frame_map(winstate, original_pos, RF_UNMATCHED);
+		return;
+	}
+
+#ifdef RPR_DEBUG
+	elog(DEBUG2, "pos: " INT64_FORMAT " encoded_str: %s",
+		 pos, encoded_str->data);
+#endif
+
+	/* build regular expression */
+	pattern_str = makeStringInfo();
+	appendStringInfoChar(pattern_str, '^');
+	initial_index = 0;
+
+	variable_pos = variable_pos_init();
+
+	forboth(lc1, winstate->patternVariableList,
+			lc2, winstate->patternRegexpList)
+	{
+		char	   *vname = strVal(lfirst(lc1));
+		char	   *quantifier = strVal(lfirst(lc2));
+		char		initial;
+
+		initial = pattern_initial(winstate, vname);
+		Assert(initial != 0);
+		appendStringInfoChar(pattern_str, initial);
+		if (quantifier[0])
+			appendStringInfoChar(pattern_str, quantifier[0]);
+
+		/*
+		 * Register the initial at initial_index. If the initial appears more
+		 * than once, all of it's initial_index will be recorded. This could
+		 * happen if a pattern variable appears in the PATTERN clause more
+		 * than once like "UP DOWN UP" "UP UP UP".
+		 */
+		variable_pos_register(variable_pos, initial, initial_index);
+
+		initial_index++;
+	}
+
+#ifdef RPR_DEBUG
+	elog(DEBUG2, "pos: " INT64_FORMAT " pattern: %s",
+		 pos, pattern_str->data);
+#endif
+
+	/* look for matching pattern variable sequence */
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "search_str_set started");
+#endif
+	num_matched_rows = search_str_set(pattern_str->data,
+									  str_set, variable_pos);
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "search_str_set returns: %d", num_matched_rows);
+#endif
+	variable_pos_discard(variable_pos);
+	string_set_discard(str_set);
+
+	/*
+	 * We are at the first row in the reduced frame.  Save the number of
+	 * matched rows as the number of rows in the reduced frame.
+	 */
+	if (num_matched_rows <= 0)
+	{
+		/* no match */
+		register_reduced_frame_map(winstate, original_pos, RF_UNMATCHED);
+	}
+	else
+	{
+		register_reduced_frame_map(winstate, original_pos, RF_FRAME_HEAD);
+
+		for (i = original_pos + 1; i < original_pos + num_matched_rows; i++)
+		{
+			register_reduced_frame_map(winstate, i, RF_SKIPPED);
+		}
+	}
+
+	return;
+}
+
+/*
+ * search_str_set
+ * Perform pattern matching using "pattern" against str_set. pattern is a
+ * regular expression derived from PATTERN clause. Note that the regular
+ * expression string is prefixed by '^' and followed by initials represented
+ * in a same way as str_set. str_set is a set of StringInfo. Each StringInfo
+ * has a string comprising initials of pattern variable strings being true in
+ * a row. The initials are one of [a-y], parallel to the order of variable
+ * names in DEFINE clause. Suppose DEFINE has variables START, UP and DOWN. If
+ * PATTERN has START, UP+ and DOWN, then the initials in PATTERN will be 'a',
+ * 'b' and 'c'. The "pattern" will be "^ab+c".
+ *
+ * variable_pos is an array representing the order of pattern variable string
+ * initials in PATTERN clause.  For example initial 'a' potion is in
+ * variable_pos[0].pos[0] = 0. Note that if the pattern is "START UP DOWN UP"
+ * (UP appears twice), then "UP" (initial is 'b') has two position 1 and
+ * 3. Thus variable_pos for b is variable_pos[1].pos[0] = 1 and
+ * variable_pos[1].pos[1] = 3.
+ *
+ * Returns the longest number of the matching rows (greedy matching) if
+ * quatifier '+' or '*' is included in "pattern".
+ */
+static
+int
+search_str_set(char *pattern, StringSet * str_set, VariablePos * variable_pos)
+{
+#define	MAX_CANDIDATE_NUM	10000	/* max pattern match candidate size */
+#define	FREEZED_CHAR	'Z'		/* a pattern is freezed if it ends with the
+								 * char */
+#define	DISCARD_CHAR	'z'		/* a pattern is not need to keep */
+
+	int			set_size;		/* number of rows in the set */
+	int			resultlen;
+	int			index;
+	StringSet  *old_str_set,
+			   *new_str_set;
+	int			new_str_size;
+	int			len;
+
+	set_size = string_set_get_size(str_set);
+	new_str_set = string_set_init();
+	len = 0;
+	resultlen = 0;
+
+	/*
+	 * Generate all possible pattern variable name initials as a set of
+	 * StringInfo named "new_str_set".  For example, if we have two rows
+	 * having "ab" (row 0) and "ac" (row 1) in the input str_set, new_str_set
+	 * will have set of StringInfo "aa", "ac", "ba" and "bc" in the end.
+	 */
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "pattern: %s set_size: %d", pattern, set_size);
+#endif
+	for (index = 0; index < set_size; index++)
+	{
+		StringInfo	str;		/* search target row */
+		char	   *p;
+		int			old_set_size;
+		int			i;
+
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "index: %d", index);
+#endif
+		if (index == 0)
+		{
+			/* copy variables in row 0 */
+			str = string_set_get(str_set, index);
+			p = str->data;
+
+			/*
+			 * Loop over each new pattern variable char.
+			 */
+			while (*p)
+			{
+				StringInfo	new = makeStringInfo();
+
+				/* add pattern variable char */
+				appendStringInfoChar(new, *p);
+				/* add new one to string set */
+				string_set_add(new_str_set, new);
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "old_str: NULL new_str: %s", new->data);
+#endif
+				p++;			/* next pattern variable */
+			}
+		}
+		else					/* index != 0 */
+		{
+			old_str_set = new_str_set;
+			new_str_set = string_set_init();
+			str = string_set_get(str_set, index);
+			old_set_size = string_set_get_size(old_str_set);
+
+			/*
+			 * Loop over each rows in the previous result set.
+			 */
+			for (i = 0; i < old_set_size; i++)
+			{
+				StringInfo	new;
+				char		last_old_char;
+				int			old_str_len;
+				StringInfo	old = string_set_get(old_str_set, i);
+
+				p = old->data;
+				old_str_len = strlen(p);
+				if (old_str_len > 0)
+					last_old_char = p[old_str_len - 1];
+				else
+					last_old_char = '\0';
+
+				/* Is this old set freezed? */
+				if (last_old_char == FREEZED_CHAR)
+				{
+					/* if shorter match. we can discard it */
+					if ((old_str_len - 1) < resultlen)
+					{
+#ifdef RPR_DEBUG
+						elog(DEBUG1, "discard this old set because shorter match: %s",
+							 old->data);
+#endif
+						continue;
+					}
+
+#ifdef RPR_DEBUG
+					elog(DEBUG1, "keep this old set: %s", old->data);
+#endif
+
+					/* move the old set to new_str_set */
+					string_set_add(new_str_set, old);
+					old_str_set->str_set[i] = NULL;
+					continue;
+				}
+				/* Can this old set be discarded? */
+				else if (last_old_char == DISCARD_CHAR)
+				{
+#ifdef RPR_DEBUG
+					elog(DEBUG1, "discard this old set: %s", old->data);
+#endif
+					continue;
+				}
+
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "str->data: %s", str->data);
+#endif
+
+				/*
+				 * loop over each pattern variable initial char in the input
+				 * set.
+				 */
+				for (p = str->data; *p; p++)
+				{
+					/*
+					 * Optimization.  Check if the row's pattern variable
+					 * initial character position is greater than or equal to
+					 * the old set's last pattern variable initial character
+					 * position. For example, if the old set's last pattern
+					 * variable initials are "ab", then the new pattern
+					 * variable initial can be "b" or "c" but can not be "a",
+					 * if the initials in PATTERN is something like "a b c" or
+					 * "a b+ c+" etc.  This optimization is possible when we
+					 * only allow "+" quantifier.
+					 */
+					if (variable_pos_compare(variable_pos, last_old_char, *p))
+					{
+						/* copy source string */
+						new = makeStringInfo();
+						enlargeStringInfo(new, old->len + 1);
+						appendStringInfoString(new, old->data);
+						/* add pattern variable char */
+						appendStringInfoChar(new, *p);
+#ifdef RPR_DEBUG
+						elog(DEBUG1, "old_str: %s new_str: %s",
+							 old->data, new->data);
+#endif
+
+						/*
+						 * Adhoc optimization. If the first letter in the
+						 * input string is the first and second position one
+						 * and there's no associated quatifier '+', then we
+						 * can dicard the input because there's no chace to
+						 * expand the string further.
+						 *
+						 * For example, pattern "abc" cannot match "aa".
+						 */
+#ifdef RPR_DEBUG
+						elog(DEBUG1, "pattern[1]:%c pattern[2]:%c new[0]:%c new[1]:%c",
+							 pattern[1], pattern[2], new->data[0], new->data[1]);
+#endif
+						if (pattern[1] == new->data[0] &&
+							pattern[1] == new->data[1] &&
+							pattern[2] != '+' &&
+							pattern[1] != pattern[2])
+						{
+#ifdef RPR_DEBUG
+							elog(DEBUG1, "discard this new data: %s",
+								 new->data);
+#endif
+							pfree(new->data);
+							pfree(new);
+							continue;
+						}
+
+						/* add new one to string set */
+						string_set_add(new_str_set, new);
+					}
+					else
+					{
+						/*
+						 * We are freezing this pattern string.  Since there's
+						 * no chance to expand the string further, we perform
+						 * pattern matching against the string. If it does not
+						 * match, we can discard it.
+						 */
+						len = do_pattern_match(pattern, old->data);
+
+						if (len <= 0)
+						{
+							/* no match. we can discard it */
+							continue;
+						}
+
+						else if (len <= resultlen)
+						{
+							/* shorter match. we can discard it */
+							continue;
+						}
+						else
+						{
+							/* match length is the longest so far */
+
+							int			new_index;
+
+							/* remember the longest match */
+							resultlen = len;
+
+							/* freeze the pattern string */
+							new = makeStringInfo();
+							enlargeStringInfo(new, old->len + 1);
+							appendStringInfoString(new, old->data);
+							/* add freezed mark */
+							appendStringInfoChar(new, FREEZED_CHAR);
+#ifdef RPR_DEBUG
+							elog(DEBUG1, "old_str: %s new_str: %s", old->data, new->data);
+#endif
+							string_set_add(new_str_set, new);
+
+							/*
+							 * Search new_str_set to find out freezed entries
+							 * that have shorter match length. Mark them as
+							 * "discard" so that they are discarded in the
+							 * next round.
+							 */
+
+							/* new_index_size should be the one before */
+							new_str_size =
+								string_set_get_size(new_str_set) - 1;
+
+							/* loop over new_str_set */
+							for (new_index = 0; new_index < new_str_size;
+								 new_index++)
+							{
+								char		new_last_char;
+								int			new_str_len;
+
+								new = string_set_get(new_str_set, new_index);
+								new_str_len = strlen(new->data);
+								if (new_str_len > 0)
+								{
+									new_last_char =
+										new->data[new_str_len - 1];
+									if (new_last_char == FREEZED_CHAR &&
+										(new_str_len - 1) <= len)
+									{
+										/*
+										 * mark this set to discard in the
+										 * next round
+										 */
+										appendStringInfoChar(new, DISCARD_CHAR);
+#ifdef RPR_DEBUG
+										elog(DEBUG1, "add discard char: %s", new->data);
+#endif
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+			/* we no longer need old string set */
+			string_set_discard(old_str_set);
+		}
+	}
+
+	/*
+	 * Perform pattern matching to find out the longest match.
+	 */
+	new_str_size = string_set_get_size(new_str_set);
+#ifdef RPR_DEBUG
+	elog(DEBUG1, "new_str_size: %d", new_str_size);
+#endif
+	len = 0;
+	resultlen = 0;
+
+	for (index = 0; index < new_str_size; index++)
+	{
+		StringInfo	s;
+
+		s = string_set_get(new_str_set, index);
+		if (s == NULL)
+			continue;			/* no data */
+
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "target string: %s", s->data);
+#endif
+		len = do_pattern_match(pattern, s->data);
+		if (len > resultlen)
+		{
+			/* remember the longest match */
+			resultlen = len;
+
+			/*
+			 * If the size of result set is equal to the number of rows in the
+			 * set, we are done because it's not possible that the number of
+			 * matching rows exceeds the number of rows in the set.
+			 */
+			if (resultlen >= set_size)
+				break;
+		}
+	}
+
+	/* we no longer need new string set */
+	string_set_discard(new_str_set);
+
+	return resultlen;
+}
+
+/*
+ * do_pattern_match
+ * perform pattern match using pattern against encoded_str.
+ * returns matching number of rows if matching is succeeded.
+ * Otherwise returns 0.
+ */
+static
+int
+do_pattern_match(char *pattern, char *encoded_str)
+{
+	Datum		d;
+	text	   *res;
+	char	   *substr;
+	int			len = 0;
+	text	   *pattern_text,
+			   *encoded_str_text;
+
+	pattern_text = cstring_to_text(pattern);
+	encoded_str_text = cstring_to_text(encoded_str);
+
+	/*
+	 * We first perform pattern matching using regexp_instr, then call
+	 * textregexsubstr to get matched substring to know how long the matched
+	 * string is. That is the number of rows in the reduced window frame.  The
+	 * reason why we can't call textregexsubstr in the first place is, it
+	 * errors out if pattern does not match.
+	 */
+	if (DatumGetInt32(DirectFunctionCall2Coll(
+						  regexp_instr, DEFAULT_COLLATION_OID,
+						  PointerGetDatum(encoded_str_text),
+						  PointerGetDatum(pattern_text))))
+	{
+		d = DirectFunctionCall2Coll(textregexsubstr,
+									DEFAULT_COLLATION_OID,
+									PointerGetDatum(encoded_str_text),
+									PointerGetDatum(pattern_text));
+		if (d != 0)
+		{
+			res = DatumGetTextPP(d);
+			substr = text_to_cstring(res);
+			len = strlen(substr);
+			pfree(substr);
+		}
+	}
+	pfree(encoded_str_text);
+	pfree(pattern_text);
+
+	return len;
+}
+
+/*
+ * evaluate_pattern
+ * Evaluate expression associated with PATTERN variable vname.  current_pos is
+ * relative row position in a frame (starting from 0). If vname is evaluated
+ * to true, initial letters associated with vname is appended to
+ * encode_str. result is out paramater representing the expression evaluation
+ * result is true of false.
+ *---------
+ * Return values are:
+ * >=0: the last match absolute row position
+ * otherwise out of frame.
+ *---------
+ */
+static
+int64
+evaluate_pattern(WindowObject winobj, int64 current_pos,
+				 char *vname, StringInfo encoded_str, bool *result)
+{
+	WindowAggState *winstate = winobj->winstate;
+	ExprContext *econtext = winstate->ss.ps.ps_ExprContext;
+	ListCell   *lc1,
+			   *lc2,
+			   *lc3;
+	ExprState  *pat;
+	Datum		eval_result;
+	bool		out_of_frame = false;
+	bool		isnull;
+	TupleTableSlot *slot;
+
+	forthree(lc1, winstate->defineVariableList,
+			 lc2, winstate->defineClauseList,
+			 lc3, winstate->defineInitial)
+	{
+		char		initial;	/* initial letter associated with vname */
+		char	   *name = strVal(lfirst(lc1));
+
+		if (strcmp(vname, name))
+			continue;
+
+		initial = *(strVal(lfirst(lc3)));
+
+		/* set expression to evaluate */
+		pat = lfirst(lc2);
+
+		/* get current, previous and next tuples */
+		if (!get_slots(winobj, current_pos))
+		{
+			out_of_frame = true;
+		}
+		else
+		{
+			/* evaluate the expression */
+			eval_result = ExecEvalExpr(pat, econtext, &isnull);
+			if (isnull)
+			{
+				/* expression is NULL */
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "expression for %s is NULL at row: " INT64_FORMAT,
+					 vname, current_pos);
+#endif
+				*result = false;
+			}
+			else
+			{
+				if (!DatumGetBool(eval_result))
+				{
+					/* expression is false */
+#ifdef RPR_DEBUG
+					elog(DEBUG1, "expression for %s is false at row: " INT64_FORMAT,
+						 vname, current_pos);
+#endif
+					*result = false;
+				}
+				else
+				{
+					/* expression is true */
+#ifdef RPR_DEBUG
+					elog(DEBUG1, "expression for %s is true at row: " INT64_FORMAT,
+						 vname, current_pos);
+#endif
+					appendStringInfoChar(encoded_str, initial);
+					*result = true;
+				}
+			}
+
+			slot = winstate->temp_slot_1;
+			if (slot != winstate->null_slot)
+				ExecClearTuple(slot);
+			slot = winstate->prev_slot;
+			if (slot != winstate->null_slot)
+				ExecClearTuple(slot);
+			slot = winstate->next_slot;
+			if (slot != winstate->null_slot)
+				ExecClearTuple(slot);
+
+			break;
+		}
+
+		if (out_of_frame)
+		{
+			*result = false;
+			return -1;
+		}
+	}
+	return current_pos;
+}
+
+/*
+ * get_slots
+ * Get current, previous and next tuples.
+ * Returns false if current row is out of partition/full frame.
+ */
+static
+bool
+get_slots(WindowObject winobj, int64 current_pos)
+{
+	WindowAggState *winstate = winobj->winstate;
+	TupleTableSlot *slot;
+	int			ret;
+	ExprContext *econtext;
+
+	econtext = winstate->ss.ps.ps_ExprContext;
+
+	/* set up current row tuple slot */
+	slot = winstate->temp_slot_1;
+	if (!window_gettupleslot(winobj, current_pos, slot))
+	{
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "current row is out of partition at:" INT64_FORMAT,
+			 current_pos);
+#endif
+		return false;
+	}
+	ret = row_is_in_frame(winstate, current_pos, slot);
+	if (ret <= 0)
+	{
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "current row is out of frame at: " INT64_FORMAT,
+			 current_pos);
+#endif
+		ExecClearTuple(slot);
+		return false;
+	}
+	econtext->ecxt_outertuple = slot;
+
+	/* for PREV */
+	if (current_pos > 0)
+	{
+		slot = winstate->prev_slot;
+		if (!window_gettupleslot(winobj, current_pos - 1, slot))
+		{
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "previous row is out of partition at: " INT64_FORMAT,
+				 current_pos - 1);
+#endif
+			econtext->ecxt_scantuple = winstate->null_slot;
+		}
+		else
+		{
+			ret = row_is_in_frame(winstate, current_pos - 1, slot);
+			if (ret <= 0)
+			{
+#ifdef RPR_DEBUG
+				elog(DEBUG1, "previous row is out of frame at: " INT64_FORMAT,
+					 current_pos - 1);
+#endif
+				ExecClearTuple(slot);
+				econtext->ecxt_scantuple = winstate->null_slot;
+			}
+			else
+			{
+				econtext->ecxt_scantuple = slot;
+			}
+		}
+	}
+	else
+		econtext->ecxt_scantuple = winstate->null_slot;
+
+	/* for NEXT */
+	slot = winstate->next_slot;
+	if (!window_gettupleslot(winobj, current_pos + 1, slot))
+	{
+#ifdef RPR_DEBUG
+		elog(DEBUG1, "next row is out of partiton at: " INT64_FORMAT,
+			 current_pos + 1);
+#endif
+		econtext->ecxt_innertuple = winstate->null_slot;
+	}
+	else
+	{
+		ret = row_is_in_frame(winstate, current_pos + 1, slot);
+		if (ret <= 0)
+		{
+#ifdef RPR_DEBUG
+			elog(DEBUG1, "next row is out of frame at: " INT64_FORMAT,
+				 current_pos + 1);
+#endif
+			ExecClearTuple(slot);
+			econtext->ecxt_innertuple = winstate->null_slot;
+		}
+		else
+			econtext->ecxt_innertuple = slot;
+	}
+	return true;
+}
+
+/*
+ * pattern_initial
+ * Return pattern variable initial character
+ * matching with pattern variable name vname.
+ * If not found, return 0.
+ */
+static
+char
+pattern_initial(WindowAggState *winstate, char *vname)
+{
+	char		initial;
+	char	   *name;
+	ListCell   *lc1,
+			   *lc2;
+
+	forboth(lc1, winstate->defineVariableList,
+			lc2, winstate->defineInitial)
+	{
+		name = strVal(lfirst(lc1)); /* DEFINE variable name */
+		initial = *(strVal(lfirst(lc2)));	/* DEFINE variable initial */
+
+
+		if (!strcmp(name, vname))
+			return initial;		/* found */
+	}
+	return 0;
+}
+
+/*
+ * string_set_init
+ * Create dynamic set of StringInfo.
+ */
+static
+StringSet * string_set_init(void)
+{
+/* Initial allocation size of str_set */
+#define STRING_SET_ALLOC_SIZE	1024
+
+	StringSet  *string_set;
+	Size		set_size;
+
+	string_set = palloc0(sizeof(StringSet));
+	string_set->set_index = 0;
+	set_size = STRING_SET_ALLOC_SIZE;
+	string_set->str_set = palloc(set_size * sizeof(StringInfo));
+	string_set->set_size = set_size;
+
+	return string_set;
+}
+
+/*
+ * string_set_add
+ * Add StringInfo str to StringSet string_set.
+ */
+static
+void
+string_set_add(StringSet * string_set, StringInfo str)
+{
+	Size		set_size;
+
+	set_size = string_set->set_size;
+	if (string_set->set_index >= set_size)
+	{
+		set_size *= 2;
+		string_set->str_set = repalloc(string_set->str_set,
+									   set_size * sizeof(StringInfo));
+		string_set->set_size = set_size;
+	}
+
+	string_set->str_set[string_set->set_index++] = str;
+
+	return;
+}
+
+/*
+ * string_set_get
+ * Returns StringInfo specified by index.
+ * If there's no data yet, returns NULL.
+ */
+static
+StringInfo
+string_set_get(StringSet * string_set, int index)
+{
+	/* no data? */
+	if (index == 0 && string_set->set_index == 0)
+		return NULL;
+
+	if (index < 0 || index >= string_set->set_index)
+		elog(ERROR, "invalid index: %d", index);
+
+	return string_set->str_set[index];
+}
+
+/*
+ * string_set_get_size
+ * Returns the size of StringSet.
+ */
+static
+int
+string_set_get_size(StringSet * string_set)
+{
+	return string_set->set_index;
+}
+
+/*
+ * string_set_discard
+ * Discard StringSet.
+ * All memory including StringSet itself is freed.
+ */
+static
+void
+string_set_discard(StringSet * string_set)
+{
+	int			i;
+
+	for (i = 0; i < string_set->set_index; i++)
+	{
+		StringInfo	str = string_set->str_set[i];
+
+		if (str)
+		{
+			pfree(str->data);
+			pfree(str);
+		}
+	}
+	pfree(string_set->str_set);
+	pfree(string_set);
+}
+
+/*
+ * variable_pos_init
+ * Create and initialize variable postion structure
+ */
+static
+VariablePos * variable_pos_init(void)
+{
+	VariablePos *variable_pos;
+
+	variable_pos = palloc(sizeof(VariablePos) * NUM_ALPHABETS);
+	MemSet(variable_pos, -1, sizeof(VariablePos) * NUM_ALPHABETS);
+	return variable_pos;
+}
+
+/*
+ * variable_pos_register
+ * Register pattern variable whose initial is initial into postion index.
+ * pos is position of initial.
+ * If pos is already registered, register it at next empty slot.
+ */
+static
+void
+variable_pos_register(VariablePos * variable_pos, char initial, int pos)
+{
+	int			index = initial - 'a';
+	int			slot;
+	int			i;
+
+	if (pos < 0 || pos > NUM_ALPHABETS)
+		elog(ERROR, "initial is not valid char: %c", initial);
+
+	for (i = 0; i < NUM_ALPHABETS; i++)
+	{
+		slot = variable_pos[index].pos[i];
+		if (slot < 0)
+		{
+			/* empty slot found */
+			variable_pos[index].pos[i] = pos;
+			return;
+		}
+	}
+	elog(ERROR, "no empty slot for initial: %c", initial);
+}
+
+/*
+ * variable_pos_compare
+ * Returns true if initial1 can be followed by initial2
+ */
+static
+bool
+variable_pos_compare(VariablePos * variable_pos, char initial1, char initial2)
+{
+	int			index1,
+				index2;
+	int			pos1,
+				pos2;
+
+	for (index1 = 0;; index1++)
+	{
+		pos1 = variable_pos_fetch(variable_pos, initial1, index1);
+		if (pos1 < 0)
+			break;
+
+		for (index2 = 0;; index2++)
+		{
+			pos2 = variable_pos_fetch(variable_pos, initial2, index2);
+			if (pos2 < 0)
+				break;
+			if (pos1 <= pos2)
+				return true;
+		}
+	}
+	return false;
+}
+
+/*
+ * variable_pos_fetch
+ * Fetch position of pattern variable whose initial is initial, and whose index
+ * is index. If no postion was registered by initial, index, returns -1.
+ */
+static
+int
+variable_pos_fetch(VariablePos * variable_pos, char initial, int index)
+{
+	int			pos = initial - 'a';
+
+	if (pos < 0 || pos > NUM_ALPHABETS)
+		elog(ERROR, "initial is not valid char: %c", initial);
+
+	if (index < 0 || index > NUM_ALPHABETS)
+		elog(ERROR, "index is not valid: %d", index);
+
+	return variable_pos[pos].pos[index];
+}
+
+/*
+ * variable_pos_discard
+ * Discard VariablePos
+ */
+static
+void
+variable_pos_discard(VariablePos * variable_pos)
+{
+	pfree(variable_pos);
+}
diff --git a/src/backend/utils/adt/windowfuncs.c b/src/backend/utils/adt/windowfuncs.c
index 473c61569f..92c528d38c 100644
--- a/src/backend/utils/adt/windowfuncs.c
+++ b/src/backend/utils/adt/windowfuncs.c
@@ -13,6 +13,9 @@
  */
 #include "postgres.h"
 
+#include "catalog/pg_collation_d.h"
+#include "executor/executor.h"
+#include "nodes/execnodes.h"
 #include "nodes/parsenodes.h"
 #include "nodes/supportnodes.h"
 #include "utils/fmgrprotos.h"
@@ -37,11 +40,19 @@ typedef struct
 	int64		remainder;		/* (total rows) % (bucket num) */
 } ntile_context;
 
+/*
+ * rpr process information.
+ * Used for AFTER MATCH SKIP PAST LAST ROW
+ */
+typedef struct SkipContext
+{
+	int64		pos;			/* last row absolute position */
+}			SkipContext;
+
 static bool rank_up(WindowObject winobj);
 static Datum leadlag_common(FunctionCallInfo fcinfo,
 							bool forward, bool withoffset, bool withdefault);
 
-
 /*
  * utility routine for *_rank functions.
  */
@@ -674,7 +685,7 @@ window_last_value(PG_FUNCTION_ARGS)
 	bool		isnull;
 
 	result = WinGetFuncArgInFrame(winobj, 0,
-								  0, WINDOW_SEEK_TAIL, true,
+								  0, WINDOW_SEEK_TAIL, false,
 								  &isnull, NULL);
 	if (isnull)
 		PG_RETURN_NULL();
@@ -714,3 +725,25 @@ window_nth_value(PG_FUNCTION_ARGS)
 
 	PG_RETURN_DATUM(result);
 }
+
+/*
+ * prev
+ * Dummy function to invoke RPR's navigation operator "PREV".
+ * This is *not* a window function.
+ */
+Datum
+window_prev(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+}
+
+/*
+ * next
+ * Dummy function to invoke RPR's navigation operation "NEXT".
+ * This is *not* a window function.
+ */
+Datum
+window_next(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_DATUM(PG_GETARG_DATUM(0));
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 134e3b22fd..5f7fb538f9 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -10477,6 +10477,12 @@
 { oid => '3114', descr => 'fetch the Nth row value',
   proname => 'nth_value', prokind => 'w', prorettype => 'anyelement',
   proargtypes => 'anyelement int4', prosrc => 'window_nth_value' },
+{ oid => '6122', descr => 'previous value',
+  proname => 'prev', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_prev' },
+{ oid => '6123', descr => 'next value',
+  proname => 'next', provolatile => 's', prorettype => 'anyelement',
+  proargtypes => 'anyelement', prosrc => 'window_next' },
 
 # functions for range types
 { oid => '3832', descr => 'I/O',
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index d927ac44a8..971d8682b1 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2550,6 +2550,11 @@ typedef enum WindowAggStatus
 									 * tuples during spool */
 } WindowAggStatus;
 
+#define	RF_NOT_DETERMINED	0
+#define	RF_FRAME_HEAD		1
+#define	RF_SKIPPED			2
+#define	RF_UNMATCHED		3
+
 typedef struct WindowAggState
 {
 	ScanState	ss;				/* its first field is NodeTag */
@@ -2598,6 +2603,19 @@ typedef struct WindowAggState
 	int64		groupheadpos;	/* current row's peer group head position */
 	int64		grouptailpos;	/* " " " " tail position (group end+1) */
 
+	/* these fields are used in Row pattern recognition: */
+	RPSkipTo	rpSkipTo;		/* Row Pattern Skip To type */
+	List	   *patternVariableList;	/* list of row pattern variables names
+										 * (list of String) */
+	List	   *patternRegexpList;	/* list of row pattern regular expressions
+									 * ('+' or ''. list of String) */
+	List	   *defineVariableList; /* list of row pattern definition
+									 * variables (list of String) */
+	List	   *defineClauseList;	/* expression for row pattern definition
+									 * search conditions ExprState list */
+	List	   *defineInitial;	/* list of row pattern definition variable
+								 * initials (list of String) */
+
 	MemoryContext partcontext;	/* context for partition-lifespan data */
 	MemoryContext aggcontext;	/* shared context for aggregate working data */
 	MemoryContext curaggcontext;	/* current aggregate's working data */
@@ -2634,6 +2652,18 @@ typedef struct WindowAggState
 	TupleTableSlot *agg_row_slot;
 	TupleTableSlot *temp_slot_1;
 	TupleTableSlot *temp_slot_2;
+
+	/* temporary slots for RPR */
+	TupleTableSlot *prev_slot;	/* PREV row navigation operator */
+	TupleTableSlot *next_slot;	/* NEXT row navigation operator */
+	TupleTableSlot *null_slot;	/* all NULL slot */
+
+	/*
+	 * Each byte corresponds to a row positioned at absolute its pos in
+	 * partition.  See above definition for RF_*
+	 */
+	char	   *reduced_frame_map;
+	int64		alloc_sz;		/* size of the map */
 } WindowAggState;
 
 /* ----------------
-- 
2.25.1


----Next_Part(Sun_Apr_28_20_28_26_2024_444)--
Content-Type: Text/X-Patch; charset=us-ascii
Content-Transfer-Encoding: 7bit
Content-Disposition: inline;
 filename="v17-0006-Row-pattern-recognition-patch-docs.patch"



^ permalink  raw  reply  [nested|flat] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ 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; 234+ 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] 234+ messages in thread


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

Thread overview: 234+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2024-04-28 11:00 [PATCH v17 5/8] Row pattern recognition patch (executor). Tatsuo Ishii <[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-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-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-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-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-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]>
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