agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v12 2/2] handle relation statistics correctly during rewrites 168+ messages / 2 participants [nested] [flat]
* [PATCH v12 2/2] handle relation statistics correctly during rewrites @ 2025-11-04 13:52 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 168+ messages in thread From: Bertrand Drouvot @ 2025-11-04 13:52 UTC (permalink / raw) Now that PGSTAT_KIND_RELATION is keyed by refilenode, we need to handle rewrites. To do so, this patch: - Adds PgStat_PendingRewrite, a new struct to track rewrite operations within a transaction, storing the old locator, new locator, and original locator (for rewrite chains). This allows stats to be copied from the original location to the final location at commit time. - Adds a new function, pgstat_mark_rewrite(), called when a table rewrite begins. It records the rewrite operation in a local list and detects rewrite chains by checking if the old_locator matches any existing new_locator, preserving the chain's original_locator. - Modifies pgstat_copy_relation_stats(), to accept RelFileLocators instead of Relations, with a new increment parameter to accumulate stats (needed for rewrite chains with DML between rewrites). - Ensures that AtEOXact_PgStat_Relations(), AtPrepare_PgStat_Relations(), pgstat_twophase_postcommit()/postabort() pgstat_drop_relation() handle the PgStat_PendingRewrite list correctly. Note that due to the new flush call in pgstat_twophase_postcommit() we can not call GetCurrentTransactionStopTimestamp() in pgstat_relation_flush_cb(). So, adding a check to handle this special case and call GetCurrentTimestamp() instead. Note that we'd call GetCurrentTimestamp() only if there is a rewrite, so that the GetCurrentTimestamp() extra cost should be negligible. Another solution could be to trigger the flush from FinishPreparedTransaction() but that's not worth the extra complexity. The new pending_rewrites list is traversed in multiple places. The overhead should be negligible in comparison to a rewrite and the list should not contain a lot of rewrites in practice. The pending_rewrites list is traversed in multiple places. In typical usage, the list will contain only a few entries so the traversal cost is negligible ( furthermore in comparison to a rewrite). --- src/backend/catalog/index.c | 2 +- src/backend/commands/cluster.c | 5 + src/backend/commands/tablecmds.c | 6 + src/backend/utils/activity/pgstat_relation.c | 391 ++++++++++++++++++- src/backend/utils/activity/pgstat_xact.c | 25 +- src/backend/utils/cache/relcache.c | 6 + src/include/pgstat.h | 5 +- src/tools/pgindent/typedefs.list | 1 + 8 files changed, 424 insertions(+), 17 deletions(-) 92.8% src/backend/utils/activity/ 4.9% src/backend/ diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c index 43de42ce39e..88ef00609db 100644 --- a/src/backend/catalog/index.c +++ b/src/backend/catalog/index.c @@ -1793,7 +1793,7 @@ index_concurrently_swap(Oid newIndexId, Oid oldIndexId, const char *oldName) changeDependenciesOn(RelationRelationId, oldIndexId, newIndexId); /* copy over statistics from old to new index */ - pgstat_copy_relation_stats(newClassRel, oldClassRel); + pgstat_copy_relation_stats(newClassRel->rd_locator, oldClassRel->rd_locator, false); /* Copy data of pg_statistic from the old index to the new one */ CopyStatistics(oldIndexId, newIndexId); diff --git a/src/backend/commands/cluster.c b/src/backend/commands/cluster.c index 60a4617a585..3a446f32921 100644 --- a/src/backend/commands/cluster.c +++ b/src/backend/commands/cluster.c @@ -1196,6 +1196,11 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, rel1 = relation_open(r1, NoLock); rel2 = relation_open(r2, NoLock); + + /* Mark that a rewrite happened */ + if (RELKIND_HAS_STORAGE(rel1->rd_rel->relkind)) + pgstat_mark_rewrite(rel1->rd_locator, rel2->rd_locator); + rel2->rd_createSubid = rel1->rd_createSubid; rel2->rd_newRelfilelocatorSubid = rel1->rd_newRelfilelocatorSubid; rel2->rd_firstRelfilelocatorSubid = rel1->rd_firstRelfilelocatorSubid; diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 85242dcc245..8d10d9d1e0f 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -16883,6 +16883,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode) Oid reltoastrelid; RelFileNumber newrelfilenumber; RelFileLocator newrlocator; + RelFileLocator oldrlocator; List *reltoastidxids = NIL; ListCell *lc; @@ -16921,6 +16922,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode) newrlocator = rel->rd_locator; newrlocator.relNumber = newrelfilenumber; newrlocator.spcOid = newTableSpace; + oldrlocator = rel->rd_locator; /* hand off to AM to actually create new rel storage and copy the data */ if (rel->rd_rel->relkind == RELKIND_INDEX) @@ -16933,6 +16935,10 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode) table_relation_copy_data(rel, &newrlocator); } + /* mark that a rewrite happened */ + if (RELKIND_HAS_STORAGE(rel->rd_rel->relkind)) + pgstat_mark_rewrite(oldrlocator, newrlocator); + /* * Update the pg_class row. * diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c index 89bf0cbed56..4d929972fab 100644 --- a/src/backend/utils/activity/pgstat_relation.c +++ b/src/backend/utils/activity/pgstat_relation.c @@ -30,6 +30,19 @@ #include "utils/syscache.h" #include "utils/timestamp.h" +/* Pending rewrite operations for stats copying */ +typedef struct PgStat_PendingRewrite +{ + RelFileLocator old_locator; + RelFileLocator new_locator; + RelFileLocator original_locator; + int nest_level; /* Transaction nesting level where rewrite + * occurred */ + struct PgStat_PendingRewrite *next; +} PgStat_PendingRewrite; + +/* The pending rewrites list for current transaction */ +static PgStat_PendingRewrite *pending_rewrites = NULL; /* Record that's written to 2PC state file when pgstat state is persisted */ typedef struct TwoPhasePgStatRecord @@ -43,6 +56,8 @@ typedef struct TwoPhasePgStatRecord PgStat_Counter deleted_pre_truncdrop; RelFileLocator locator; /* table's rd_locator */ bool truncdropped; /* was the relation truncated/dropped? */ + RelFileLocator rewrite_old_locator; + int rewrite_nest_level; } TwoPhasePgStatRecord; @@ -54,27 +69,70 @@ static void restore_truncdrop_counters(PgStat_TableXactStatus *trans); /* - * Copy stats between relations. This is used for things like REINDEX + * Copy stats between RelFileLocator. This is used for things like REINDEX * CONCURRENTLY. */ void -pgstat_copy_relation_stats(Relation dst, Relation src) +pgstat_copy_relation_stats(RelFileLocator dst, RelFileLocator src, bool increment) { PgStat_StatTabEntry *srcstats; PgStatShared_Relation *dstshstats; PgStat_EntryRef *dst_ref; - srcstats = pgstat_fetch_stat_tabentry_ext(RelationGetRelid(src)); + srcstats = (PgStat_StatTabEntry *) pgstat_fetch_entry(PGSTAT_KIND_RELATION, + src.dbOid, + RelFileLocatorToPgStatObjid(src)); if (!srcstats) return; dst_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, - dst->rd_rel->relisshared ? InvalidOid : MyDatabaseId, - RelationGetRelid(dst), + dst.dbOid, + RelFileLocatorToPgStatObjid(dst), false); dstshstats = (PgStatShared_Relation *) dst_ref->shared_stats; - dstshstats->stats = *srcstats; + + if (!increment) + dstshstats->stats = *srcstats; + else + { + /* Increment those statistics */ +#define RELFSTAT_ACC(fld, stats_to_add) \ + (dstshstats->stats.fld += stats_to_add->fld) + RELFSTAT_ACC(numscans, srcstats); + RELFSTAT_ACC(tuples_returned, srcstats); + RELFSTAT_ACC(tuples_fetched, srcstats); + RELFSTAT_ACC(tuples_inserted, srcstats); + RELFSTAT_ACC(tuples_updated, srcstats); + RELFSTAT_ACC(tuples_deleted, srcstats); + RELFSTAT_ACC(tuples_hot_updated, srcstats); + RELFSTAT_ACC(tuples_newpage_updated, srcstats); + RELFSTAT_ACC(live_tuples, srcstats); + RELFSTAT_ACC(dead_tuples, srcstats); + RELFSTAT_ACC(mod_since_analyze, srcstats); + RELFSTAT_ACC(ins_since_vacuum, srcstats); + RELFSTAT_ACC(blocks_fetched, srcstats); + RELFSTAT_ACC(blocks_hit, srcstats); + RELFSTAT_ACC(vacuum_count, srcstats); + RELFSTAT_ACC(autovacuum_count, srcstats); + RELFSTAT_ACC(analyze_count, srcstats); + RELFSTAT_ACC(autoanalyze_count, srcstats); + RELFSTAT_ACC(total_vacuum_time, srcstats); + RELFSTAT_ACC(total_autovacuum_time, srcstats); + RELFSTAT_ACC(total_analyze_time, srcstats); + RELFSTAT_ACC(total_autoanalyze_time, srcstats); +#undef RELFSTAT_ACC + + /* Replace those statistics */ +#define RELFSTAT_REP(fld, stats_to_rep) \ + (dstshstats->stats.fld = stats_to_rep->fld) + RELFSTAT_REP(lastscan, srcstats); + RELFSTAT_REP(last_vacuum_time, srcstats); + RELFSTAT_REP(last_autovacuum_time, srcstats); + RELFSTAT_REP(last_analyze_time, srcstats); + RELFSTAT_REP(last_autoanalyze_time, srcstats); +#undef RELFSTAT_REP + } pgstat_unlock_entry(dst_ref); } @@ -136,6 +194,7 @@ void pgstat_assoc_relation(Relation rel) { RelFileLocator locator; + PgStat_TableStatus *pgstat_info; Assert(rel->pgstat_enabled); Assert(rel->pgstat_info == NULL); @@ -164,14 +223,54 @@ pgstat_assoc_relation(Relation rel) locator.relNumber = rel->rd_id; } + /* + * If this relation was rewritten during the current transaction we may be + * reopening it with its new RelFileLocator. In that case, continue using + * the stats entry associated with the old locator rather than creating a + * new one. This ensures all stats from before and after the rewrite are + * tracked in a single entry which will be properly copied to the new + * locator at transaction commit. + */ + if (pending_rewrites != NULL) + { + PgStat_PendingRewrite *rewrite; + + for (rewrite = pending_rewrites; rewrite != NULL; rewrite = rewrite->next) + { + if (locator.dbOid == rewrite->new_locator.dbOid && + locator.spcOid == rewrite->new_locator.spcOid && + locator.relNumber == rewrite->new_locator.relNumber) + { + pgstat_info = pgstat_prep_relation_pending(rewrite->old_locator); + goto found_entry; + } + } + } + /* Else find or make the PgStat_TableStatus entry, and update link */ - rel->pgstat_info = pgstat_prep_relation_pending(locator); + pgstat_info = pgstat_prep_relation_pending(locator); + +found_entry: + rel->pgstat_info = pgstat_info; + + /* + * For relations stats, we key by physical file location, not by relation + * OID. This means during operations like ALTER TYPE it's possible that + * the relation OID changes but the relfilenode stays the same (no actual + * rewrite needed). Unlink the old relation first. + */ + if (pgstat_info->relation != NULL && + pgstat_info->relation != rel) + { + pgstat_info->relation->pgstat_info = NULL; + pgstat_info->relation = NULL; + } /* don't allow link a stats to multiple relcache entries */ - Assert(rel->pgstat_info->relation == NULL); + Assert(pgstat_info->relation == NULL); /* mark this relation as the owner */ - rel->pgstat_info->relation = rel; + pgstat_info->relation = rel; } /* @@ -214,14 +313,37 @@ pgstat_drop_relation(Relation rel) { int nest_level = GetCurrentTransactionNestLevel(); PgStat_TableStatus *pgstat_info; + bool skip_transactional_drop = false; /* don't track stats for relations without storage */ if (!RELKIND_HAS_STORAGE(rel->rd_rel->relkind)) return; - pgstat_drop_transactional(PGSTAT_KIND_RELATION, - rel->rd_locator.dbOid, - RelFileLocatorToPgStatObjid(rel->rd_locator)); + /* Check if this drop is part of a pending rewrite */ + if (pending_rewrites != NULL) + { + PgStat_PendingRewrite *rewrite; + + for (rewrite = pending_rewrites; rewrite != NULL; rewrite = rewrite->next) + { + if (rel->rd_locator.dbOid == rewrite->old_locator.dbOid && + rel->rd_locator.spcOid == rewrite->old_locator.spcOid && + rel->rd_locator.relNumber == rewrite->old_locator.relNumber) + { + skip_transactional_drop = true; + break; + } + } + } + + /* + * If it is part of a rewrite, drop its stats later, for example in + * AtEOXact_PgStat_Relations(), so skip it here. + */ + if (!skip_transactional_drop) + pgstat_drop_transactional(PGSTAT_KIND_RELATION, + rel->rd_locator.dbOid, + RelFileLocatorToPgStatObjid(rel->rd_locator)); if (!pgstat_should_count_relation(rel)) return; @@ -666,6 +788,48 @@ AtEOXact_PgStat_Relations(PgStat_SubXactStatus *xact_state, bool isCommit) } tabstat->trans = NULL; } + + /* preserve the stats in case of rewrite */ + if (isCommit && pending_rewrites != NULL) + { + PgStat_PendingRewrite *rewrite; + PgStat_PendingRewrite *prev = NULL; + PgStat_PendingRewrite *current = pending_rewrites; + PgStat_PendingRewrite *next; + + /* reverse the rewrites list to process in chronological order */ + while (current != NULL) + { + next = current->next; + current->next = prev; + prev = current; + current = next; + } + + /* now process rewrites in chronological order */ + for (rewrite = prev; rewrite != NULL; rewrite = rewrite->next) + { + PgStat_EntryRef *old_entry_ref; + + old_entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, + rewrite->old_locator.dbOid, + RelFileLocatorToPgStatObjid(rewrite->old_locator)); + + if (old_entry_ref && old_entry_ref->pending) + pgstat_relation_flush_cb(old_entry_ref, false); + + pgstat_copy_relation_stats(rewrite->new_locator, + rewrite->old_locator, true); + + /* drop old locator's stats */ + if (!pgstat_drop_entry(PGSTAT_KIND_RELATION, + rewrite->old_locator.dbOid, + RelFileLocatorToPgStatObjid(rewrite->old_locator))) + pgstat_request_entry_refs_gc(); + } + } + + pending_rewrites = NULL; } /* @@ -681,6 +845,30 @@ AtEOSubXact_PgStat_Relations(PgStat_SubXactStatus *xact_state, bool isCommit, in PgStat_TableXactStatus *trans; PgStat_TableXactStatus *next_trans; + /* + * If we don't commit then remove the associated rewrites if any, to keep + * the rewrite chain in sync with what will be eventually committed. + */ + if (!isCommit) + { + PgStat_PendingRewrite **rewrite_ptr = &pending_rewrites; + + while (*rewrite_ptr != NULL) + { + if ((*rewrite_ptr)->nest_level >= nestDepth) + { + PgStat_PendingRewrite *to_remove = *rewrite_ptr; + + *rewrite_ptr = (*rewrite_ptr)->next; + pfree(to_remove); + } + else + { + rewrite_ptr = &((*rewrite_ptr)->next); + } + } + } + for (trans = xact_state->first; trans != NULL; trans = next_trans) { PgStat_TableStatus *tabstat; @@ -760,11 +948,19 @@ void AtPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state) { PgStat_TableXactStatus *trans; + PgStat_PendingRewrite *rewrite; + /* + * For each tabstat, find its matching rewrite and remove it from the + * pending rewrites list. This way, after processing all tabstats, pending + * rewrites will only contain rewrite only transactions. + */ for (trans = xact_state->first; trans != NULL; trans = trans->next) { PgStat_TableStatus *tabstat PG_USED_FOR_ASSERTS_ONLY; TwoPhasePgStatRecord record; + PgStat_PendingRewrite **rewrite_ptr; + bool found_rewrite = false; Assert(trans->nest_level == 1); Assert(trans->upper == NULL); @@ -784,10 +980,83 @@ AtPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state) record.locator = tabstat->locator; record.truncdropped = trans->truncdropped; + record.rewrite_nest_level = 0; + + /* + * Look for a matching rewrite and remove it from pending rewrites. We + * check three possible matches: + * + * The new_locator when stats have been added after the rewrite. The + * old_locator when stats have been added before the rewrite but not + * after. The original_locator when this tabstat is part of a rewrite + * chain. + */ + rewrite_ptr = &pending_rewrites; + while (*rewrite_ptr != NULL) + { + rewrite = *rewrite_ptr; + + if ((record.locator.dbOid == rewrite->new_locator.dbOid && + record.locator.spcOid == rewrite->new_locator.spcOid && + record.locator.relNumber == rewrite->new_locator.relNumber) || + (tabstat->locator.dbOid == rewrite->old_locator.dbOid && + tabstat->locator.spcOid == rewrite->old_locator.spcOid && + tabstat->locator.relNumber == rewrite->old_locator.relNumber) || + (tabstat->locator.dbOid == rewrite->original_locator.dbOid && + tabstat->locator.spcOid == rewrite->original_locator.spcOid && + tabstat->locator.relNumber == rewrite->original_locator.relNumber)) + { + /* + * Found matching rewrite. Record the rewrite information and + * remove this rewrite from the list since it's now handled. + */ + record.rewrite_old_locator = rewrite->original_locator; + record.rewrite_nest_level = rewrite->nest_level; + record.locator = rewrite->new_locator; + found_rewrite = true; + + /* Remove from pending_rewrites list */ + *rewrite_ptr = rewrite->next; + pfree(rewrite); + break; + } + else + { + /* Move to next rewrite in the list */ + rewrite_ptr = &(rewrite->next); + } + } + + /* If no rewrite found, clear the rewrite fields */ + if (!found_rewrite) + { + memset(&record.rewrite_old_locator, 0, sizeof(RelFileLocator)); + } + + RegisterTwoPhaseRecord(TWOPHASE_RM_PGSTAT_ID, 0, + &record, sizeof(TwoPhasePgStatRecord)); + } + + /* + * Now process any rewrites still pending. These are rewrite only + * transactions. We need to preserve their stats even though there's no + * tabstat entry for them. + */ + for (rewrite = pending_rewrites; rewrite != NULL; rewrite = rewrite->next) + { + TwoPhasePgStatRecord record; + + memset(&record, 0, sizeof(TwoPhasePgStatRecord)); + record.locator = rewrite->new_locator; + record.rewrite_old_locator = rewrite->original_locator; + record.rewrite_nest_level = rewrite->nest_level; + record.truncdropped = false; RegisterTwoPhaseRecord(TWOPHASE_RM_PGSTAT_ID, 0, &record, sizeof(TwoPhasePgStatRecord)); } + + pending_rewrites = NULL; } /* @@ -810,6 +1079,8 @@ PostPrepare_PgStat_Relations(PgStat_SubXactStatus *xact_state) tabstat = trans->parent; tabstat->trans = NULL; } + + pending_rewrites = NULL; } /* @@ -845,6 +1116,29 @@ pgstat_twophase_postcommit(FullTransactionId fxid, uint16 info, pgstat_info->counts.changed_tuples += rec->tuples_inserted + rec->tuples_updated + rec->tuples_deleted; + + if (rec->rewrite_nest_level > 0) + { + PgStat_EntryRef *old_entry_ref; + + /* Flush any pending stats for old locator first */ + old_entry_ref = pgstat_fetch_pending_entry(PGSTAT_KIND_RELATION, + rec->rewrite_old_locator.dbOid, + RelFileLocatorToPgStatObjid(rec->rewrite_old_locator)); + + if (old_entry_ref && old_entry_ref->pending) + pgstat_relation_flush_cb(old_entry_ref, false); + + /* Copy stats from old to new locator */ + pgstat_copy_relation_stats(rec->locator, rec->rewrite_old_locator, + true); + + /* Drop old locator's stats */ + if (!pgstat_drop_entry(PGSTAT_KIND_RELATION, + rec->rewrite_old_locator.dbOid, + RelFileLocatorToPgStatObjid(rec->rewrite_old_locator))) + pgstat_request_entry_refs_gc(); + } } /* @@ -859,9 +1153,26 @@ pgstat_twophase_postabort(FullTransactionId fxid, uint16 info, { TwoPhasePgStatRecord *rec = (TwoPhasePgStatRecord *) recdata; PgStat_TableStatus *pgstat_info; + RelFileLocator target_locator; + + /* + * For aborted transactions with rewrites (like TRUNCATE), we need to + * restore stats to the old locator, not the new one. The new locator + * should be dropped since the rewrite is being rolled back. + */ + if (rec->rewrite_nest_level > 0) + { + /* Use the old locator */ + target_locator = rec->rewrite_old_locator; + } + else + { + /* No rewrite, use the original locator */ + target_locator = rec->locator; + } /* Find or create a tabstat entry for the target locator */ - pgstat_info = pgstat_prep_relation_pending(rec->locator); + pgstat_info = pgstat_prep_relation_pending(target_locator); /* Same math as in AtEOXact_PgStat, abort case */ if (rec->truncdropped) @@ -916,7 +1227,17 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait) tabentry->numscans += lstats->counts.numscans; if (lstats->counts.numscans) { - TimestampTz t = GetCurrentTransactionStopTimestamp(); + TimestampTz t; + + /* + * Checking the transaction state due to the flush call in + * pgstat_twophase_postcommit() that would break the assertion on the + * state in GetCurrentTransactionStopTimestamp(). + */ + if (!IsTransactionState()) + t = GetCurrentTransactionStopTimestamp(); + else + t = GetCurrentTimestamp(); if (t > tabentry->lastscan) tabentry->lastscan = t; @@ -1167,3 +1488,45 @@ pgstat_reloid_to_relfilelocator(Oid reloid, RelFileLocator *locator) ReleaseSysCache(tuple); return result; } + +/* + * Mark that a relation rewrite has occurred, preserving the original locator + * so stats can be copied at transaction commit. + */ +void +pgstat_mark_rewrite(RelFileLocator old_locator, RelFileLocator new_locator) +{ + PgStat_PendingRewrite *rewrite; + PgStat_PendingRewrite *existing; + RelFileLocator original_locator = old_locator; + + for (existing = pending_rewrites; existing != NULL; existing = existing->next) + { + if (old_locator.dbOid == existing->new_locator.dbOid && + old_locator.spcOid == existing->new_locator.spcOid && + old_locator.relNumber == existing->new_locator.relNumber) + { + original_locator = existing->original_locator; + break; + } + } + + /* Allocate in TopTransactionContext memory context */ + rewrite = MemoryContextAlloc(TopTransactionContext, + sizeof(PgStat_PendingRewrite)); + + rewrite->old_locator = old_locator; + rewrite->new_locator = new_locator; + rewrite->original_locator = original_locator; + rewrite->nest_level = GetCurrentTransactionNestLevel(); + + /* Add to the list */ + rewrite->next = pending_rewrites; + pending_rewrites = rewrite; +} + +void +pgstat_clear_rewrite(void) +{ + pending_rewrites = NULL; +} diff --git a/src/backend/utils/activity/pgstat_xact.c b/src/backend/utils/activity/pgstat_xact.c index 5e2d69e6297..8ed8f5317f3 100644 --- a/src/backend/utils/activity/pgstat_xact.c +++ b/src/backend/utils/activity/pgstat_xact.c @@ -55,6 +55,8 @@ AtEOXact_PgStat(bool isCommit, bool parallel) } pgStatXactStack = NULL; + pgstat_clear_rewrite(); + /* Make sure any stats snapshot is thrown away */ pgstat_clear_snapshot(); } @@ -360,8 +362,29 @@ create_drop_transactional_internal(PgStat_Kind kind, Oid dboid, uint64 objid, bo void pgstat_create_transactional(PgStat_Kind kind, Oid dboid, uint64 objid) { - if (pgstat_get_entry_ref(kind, dboid, objid, false, NULL)) + PgStat_EntryRef *entry_ref; + + entry_ref = pgstat_get_entry_ref(kind, dboid, objid, false, NULL); + + if (entry_ref) { + /* + * For relations stats, we key by physical file location, not by + * relation OID. This means during operations like ALTER TYPE where + * the relation OID changes but the relfilenode stays the same (no + * actual rewrite needed), we'll find an existing entry. + * + * This is expected behavior, we want to preserve stats across the + * catalog change. Simply reset and recreate the entry for the new + * relation OID without warning. + */ + if (kind == PGSTAT_KIND_RELATION) + { + pgstat_reset(kind, dboid, objid); + create_drop_transactional_internal(kind, dboid, objid, true); + return; + } + ereport(WARNING, errmsg("resetting existing statistics for kind %s, db=%u, oid=%" PRIu64, (pgstat_get_kind_info(kind))->name, dboid, diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c index a1c88c6b1b6..dc5948f446c 100644 --- a/src/backend/utils/cache/relcache.c +++ b/src/backend/utils/cache/relcache.c @@ -85,6 +85,7 @@ #include "utils/inval.h" #include "utils/lsyscache.h" #include "utils/memutils.h" +#include "utils/pgstat_internal.h" #include "utils/relmapper.h" #include "utils/resowner.h" #include "utils/snapmgr.h" @@ -3775,6 +3776,7 @@ RelationSetNewRelfilenumber(Relation relation, char persistence) MultiXactId minmulti = InvalidMultiXactId; TransactionId freezeXid = InvalidTransactionId; RelFileLocator newrlocator; + RelFileLocator oldrlocator = relation->rd_locator; if (!IsBinaryUpgrade) { @@ -3946,6 +3948,10 @@ RelationSetNewRelfilenumber(Relation relation, char persistence) table_close(pg_class, RowExclusiveLock); + /* Mark that a rewrite happened */ + if (RELKIND_HAS_STORAGE(relation->rd_rel->relkind)) + pgstat_mark_rewrite(oldrlocator, newrlocator); + /* * Make the pg_class row change or relation map change visible. This will * cause the relcache entry to get updated, too. diff --git a/src/include/pgstat.h b/src/include/pgstat.h index 689c624f373..d0d5473f8a5 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -673,7 +673,7 @@ extern PgStat_FunctionCounts *find_funcstat_entry(Oid func_id); extern void pgstat_create_relation(Relation rel); extern void pgstat_drop_relation(Relation rel); -extern void pgstat_copy_relation_stats(Relation dst, Relation src); +extern void pgstat_copy_relation_stats(RelFileLocator dst, RelFileLocator src, bool increment); extern void pgstat_init_relation(Relation rel); extern void pgstat_assoc_relation(Relation rel); @@ -685,6 +685,9 @@ extern void pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples, extern void pgstat_report_analyze(Relation rel, PgStat_Counter livetuples, PgStat_Counter deadtuples, bool resetcounter, TimestampTz starttime); +extern void pgstat_mark_rewrite(RelFileLocator old_locator, + RelFileLocator new_locator); +extern void pgstat_clear_rewrite(void); /* * If stats are enabled, but pending data hasn't been prepared yet, call diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 3250564d4ff..fbcbe5da26a 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2281,6 +2281,7 @@ PgStat_KindInfo PgStat_LocalState PgStat_PendingDroppedStatsItem PgStat_PendingIO +PgStat_PendingRewrite PgStat_SLRUStats PgStat_ShmemControl PgStat_Snapshot -- 2.34.1 --sdA5zuaL94ts0rne-- ^ permalink raw reply [nested|flat] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ 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; 168+ 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] 168+ messages in thread
end of thread, other threads:[~2026-07-03 05:52 UTC | newest] Thread overview: 168+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2025-11-04 13:52 [PATCH v12 2/2] handle relation statistics correctly during rewrites Bertrand Drouvot <[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-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-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]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox