agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v12] Avoid orphaned objects dependencies 186+ messages / 2 participants [nested] [flat]
* [PATCH v12] Avoid orphaned objects dependencies @ 2024-03-29 15:43 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 186+ messages in thread From: Bertrand Drouvot @ 2024-03-29 15:43 UTC (permalink / raw) It's currently possible to create orphaned objects dependencies, for example: Scenario 1: session 1: begin; drop schema schem; session 2: create a function in the schema schem session 1: commit; With the above, the function created in session 2 would be linked to a non existing schema. Scenario 2: session 1: begin; create a function in the schema schem session 2: drop schema schem; session 1: commit; With the above, the function created in session 1 would be linked to a non existing schema. To avoid those scenarios, a new lock (that conflicts with a lock taken by DROP) has been put in place before the dependencies are being recorded. With this in place, the drop schema in scenario 2 would be locked. Also, after the new lock attempt, the patch checks that the object still exists: with this in place session 2 in scenario 1 would be locked and would report an error once session 1 committs (that would not be the case should session 1 abort the transaction). If the object is dropped before the new lock attempt is triggered then the patch would also report an error (but with less details). The patch takes into account any type of objects except the ones that are pinned (they are not droppable because the system requires it). A special case is done for objects that belong to the RelationRelationId class. For those, we should be in one of the two following cases that would already prevent the relation to be dropped: 1. The relation is already locked (could be an existing relation or a relation that we are creating). 2. The relation is protected indirectly (i.e an index protected by a lock on its table, a table protected by a lock on a function that depends the table...) To avoid any risks for the RelationRelationId class case, we acquire a lock if there is none. That may add unnecessary lock for 2. but that's worth it. The patch adds a few tests for some dependency cases (that would currently produce orphaned objects): - schema and function (as the above scenarios) - alter a dependency (function and schema) - function and arg type - function and return type - function and function - domain and domain - table and type - server and foreign data wrapper --- contrib/test_decoding/expected/twophase.out | 3 +- src/backend/catalog/aclchk.c | 1 + src/backend/catalog/dependency.c | 125 ++++++++++++++++- src/backend/catalog/heap.c | 7 + src/backend/catalog/index.c | 26 ++++ src/backend/catalog/objectaddress.c | 57 ++++++++ src/backend/catalog/pg_aggregate.c | 9 ++ src/backend/catalog/pg_attrdef.c | 1 + src/backend/catalog/pg_cast.c | 5 + src/backend/catalog/pg_collation.c | 1 + src/backend/catalog/pg_constraint.c | 26 ++++ src/backend/catalog/pg_conversion.c | 2 + src/backend/catalog/pg_depend.c | 36 ++++- src/backend/catalog/pg_operator.c | 19 +++ src/backend/catalog/pg_proc.c | 17 ++- src/backend/catalog/pg_publication.c | 7 + src/backend/catalog/pg_range.c | 6 + src/backend/catalog/pg_type.c | 39 ++++++ src/backend/catalog/toasting.c | 1 + src/backend/commands/alter.c | 4 + src/backend/commands/amcmds.c | 1 + src/backend/commands/cluster.c | 7 + src/backend/commands/event_trigger.c | 1 + src/backend/commands/extension.c | 5 + src/backend/commands/foreigncmds.c | 7 + src/backend/commands/functioncmds.c | 6 + src/backend/commands/indexcmds.c | 2 + src/backend/commands/opclasscmds.c | 18 +++ src/backend/commands/operatorcmds.c | 30 ++++ src/backend/commands/policy.c | 2 + src/backend/commands/proclang.c | 3 + src/backend/commands/sequence.c | 2 + src/backend/commands/statscmds.c | 10 ++ src/backend/commands/tablecmds.c | 34 +++-- src/backend/commands/trigger.c | 29 +++- src/backend/commands/tsearchcmds.c | 81 +++++++---- src/backend/commands/typecmds.c | 84 ++++++++++++ src/backend/rewrite/rewriteDefine.c | 1 + src/backend/utils/errcodes.txt | 1 + src/include/catalog/dependency.h | 7 + src/include/catalog/objectaddress.h | 1 + src/include/storage/lock.h | 9 ++ .../expected/test_dependencies_locks.out | 129 ++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/test_dependencies_locks.spec | 89 ++++++++++++ .../test_oat_hooks/expected/alter_table.out | 4 +- .../expected/test_oat_hooks.out | 2 + src/test/regress/expected/alter_table.out | 11 +- 48 files changed, 914 insertions(+), 55 deletions(-) 32.8% src/backend/catalog/ 34.6% src/backend/commands/ 17.1% src/test/isolation/expected/ 10.7% src/test/isolation/specs/ 4.3% src/ diff --git a/contrib/test_decoding/expected/twophase.out b/contrib/test_decoding/expected/twophase.out index 517f20bc37..71581fba34 100644 --- a/contrib/test_decoding/expected/twophase.out +++ b/contrib/test_decoding/expected/twophase.out @@ -69,9 +69,10 @@ WHERE locktype = 'relation' AND relation = 'test_prepared1'::regclass; relation | locktype | mode -----------------+----------+--------------------- + test_prepared_1 | relation | AccessShareLock test_prepared_1 | relation | RowExclusiveLock test_prepared_1 | relation | AccessExclusiveLock -(2 rows) +(3 rows) -- The insert should show the newly altered column but not the DDL. SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); diff --git a/src/backend/catalog/aclchk.c b/src/backend/catalog/aclchk.c index a44ccee3b6..9a24872a30 100644 --- a/src/backend/catalog/aclchk.c +++ b/src/backend/catalog/aclchk.c @@ -1413,6 +1413,7 @@ SetDefaultACL(InternalDefaultACL *iacls) referenced.objectId = iacls->nspid; referenced.objectSubId = 0; + LockNotPinnedObject(NamespaceRelationId, iacls->nspid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); } } diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c index 0489cbabcb..782bdb580e 100644 --- a/src/backend/catalog/dependency.c +++ b/src/backend/catalog/dependency.c @@ -1519,6 +1519,100 @@ AcquireDeletionLock(const ObjectAddress *object, int flags) } } +/* + * LockNotPinnedObjectById + * + * Lock the object that we are about to record a dependency on. + * After it's locked, verify that it hasn't been dropped while we + * weren't looking. If the object has been dropped, this function + * does not return! + */ +void +LockNotPinnedObjectById(const ObjectAddress *object) +{ + char *object_description = NULL; + + if (isObjectPinned(object)) + return; + + object_description = getObjectDescription(object, true); + + if (object->classId == RelationRelationId) + { + LOCKTAG tag; + + Assert(!IsSharedRelation(object->objectId)); + + /* + * We must be in one of the two following cases that would already + * prevent the relation to be dropped: 1. The relation is already + * locked (could be an existing relation or a relation that we are + * creating). 2. The relation is protected indirectly (i.e an index + * protected by a lock on its table, a table protected by a lock on a + * function that depends of the table...). To avoid any risks, acquire + * a lock if there is none. That may add unnecessary lock for 2. but + * that's worth it. + */ + SET_LOCKTAG_RELATION(tag, MyDatabaseId, object->objectId); + + if (!ObjectIsLocked(&tag)) + LockRelationOid(object->objectId, AccessShareLock); + } + else + { + /* assume we should lock the whole object not a sub-object */ + LockDatabaseObject(object->classId, object->objectId, 0, AccessShareLock); + } + + /* check if object still exists */ + if (!ObjectByIdExist(object)) + { + if (object_description) + ereport(ERROR, + (errcode(ERRCODE_DEPENDENT_OBJECTS_DOES_NOT_EXIST), + errmsg("%s does not exist", object_description))); + else + ereport(ERROR, + (errcode(ERRCODE_DEPENDENT_OBJECTS_DOES_NOT_EXIST), + errmsg("a dependent object does not exist"))); + } + + if (object_description) + pfree(object_description); + + return; +} + +void +LockNotPinnedObjectsById(const ObjectAddress *object, int nobject) +{ + int i; + + if (nobject < 0) + return; + + for (i = 0; i < nobject; i++, object++) + LockNotPinnedObjectById(object); + + return; +} + + +/* + * LockNotPinnedObject + * + * Lock the object that we are about to record a dependency on. + */ +void +LockNotPinnedObject(Oid classid, Oid objid) +{ + ObjectAddress object; + + ObjectAddressSet(object, classid, objid); + + LockNotPinnedObjectById(&object); +} + /* * ReleaseDeletionLock - release an object deletion lock * @@ -1564,13 +1658,8 @@ recordDependencyOnExpr(const ObjectAddress *depender, /* Scan the expression tree for referenceable objects */ find_expr_references_walker(expr, &context); - /* Remove any duplicates */ - eliminate_duplicate_dependencies(context.addrs); - - /* And record 'em */ - recordMultipleDependencies(depender, - context.addrs->refs, context.addrs->numrefs, - behavior); + /* Record all of them (this includes duplicate elimination) */ + lock_record_object_address_dependencies(depender, context.addrs, behavior); free_object_addresses(context.addrs); } @@ -1654,14 +1743,19 @@ recordDependencyOnSingleRelExpr(const ObjectAddress *depender, /* Record the self-dependencies with the appropriate direction */ if (!reverse_self) + { + LockNotPinnedObjectsById(self_addrs->refs, self_addrs->numrefs); recordMultipleDependencies(depender, self_addrs->refs, self_addrs->numrefs, self_behavior); + } else { /* Can't use recordMultipleDependencies, so do it the hard way */ int selfref; + LockNotPinnedObjectById(depender); + for (selfref = 0; selfref < self_addrs->numrefs; selfref++) { ObjectAddress *thisobj = self_addrs->refs + selfref; @@ -1674,6 +1768,7 @@ recordDependencyOnSingleRelExpr(const ObjectAddress *depender, } /* Record the external dependencies */ + LockNotPinnedObjectsById(context.addrs->refs, context.addrs->numrefs); recordMultipleDependencies(depender, context.addrs->refs, context.addrs->numrefs, behavior); @@ -2734,6 +2829,22 @@ stack_address_present_add_flags(const ObjectAddress *object, return result; } +/* + * Record multiple dependencies from an ObjectAddresses array and lock the + * referenced objects, after first removing any duplicates. + */ +void +lock_record_object_address_dependencies(const ObjectAddress *depender, + ObjectAddresses *referenced, + DependencyType behavior) +{ + eliminate_duplicate_dependencies(referenced); + LockNotPinnedObjectsById(referenced->refs, referenced->numrefs); + recordMultipleDependencies(depender, + referenced->refs, referenced->numrefs, + behavior); +} + /* * Record multiple dependencies from an ObjectAddresses array, after first * removing any duplicates. diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c index a122bbffce..00977e56c4 100644 --- a/src/backend/catalog/heap.c +++ b/src/backend/catalog/heap.c @@ -843,6 +843,7 @@ AddNewAttributeTuples(Oid new_rel_oid, ObjectAddressSubSet(myself, RelationRelationId, new_rel_oid, i + 1); ObjectAddressSet(referenced, TypeRelationId, tupdesc->attrs[i].atttypid); + LockNotPinnedObject(TypeRelationId, tupdesc->attrs[i].atttypid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* The default collation is pinned, so don't bother recording it */ @@ -851,6 +852,7 @@ AddNewAttributeTuples(Oid new_rel_oid, { ObjectAddressSet(referenced, CollationRelationId, tupdesc->attrs[i].attcollation); + LockNotPinnedObject(CollationRelationId, tupdesc->attrs[i].attcollation); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } } @@ -1451,11 +1453,13 @@ heap_create_with_catalog(const char *relname, ObjectAddressSet(referenced, NamespaceRelationId, relnamespace); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(NamespaceRelationId, relnamespace); if (reloftypeid) { ObjectAddressSet(referenced, TypeRelationId, reloftypeid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, reloftypeid); } /* @@ -1469,6 +1473,7 @@ heap_create_with_catalog(const char *relname, { ObjectAddressSet(referenced, AccessMethodRelationId, accessmtd); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(AccessMethodRelationId, accessmtd); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); @@ -3383,6 +3388,7 @@ StorePartitionKey(Relation rel, { ObjectAddressSet(referenced, OperatorClassRelationId, partopclass[i]); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(OperatorClassRelationId, partopclass[i]); /* The default collation is pinned, so don't bother recording it */ if (OidIsValid(partcollation[i]) && @@ -3390,6 +3396,7 @@ StorePartitionKey(Relation rel, { ObjectAddressSet(referenced, CollationRelationId, partcollation[i]); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(CollationRelationId, partcollation[i]); } } diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c index 55fdde4b24..dcd2158422 100644 --- a/src/backend/catalog/index.c +++ b/src/backend/catalog/index.c @@ -1115,6 +1115,7 @@ index_create(Relation heapRelation, else { bool have_simple_col = false; + bool locked_object = false; addrs = new_object_addresses(); @@ -1127,6 +1128,12 @@ index_create(Relation heapRelation, heapRelationId, indexInfo->ii_IndexAttrNumbers[i]); add_exact_object_address(&referenced, addrs); + + if (!locked_object) + { + LockNotPinnedObject(RelationRelationId, heapRelationId); + locked_object = true; + } have_simple_col = true; } } @@ -1142,6 +1149,8 @@ index_create(Relation heapRelation, ObjectAddressSet(referenced, RelationRelationId, heapRelationId); add_exact_object_address(&referenced, addrs); + + LockNotPinnedObject(RelationRelationId, heapRelationId); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_AUTO); @@ -1157,9 +1166,13 @@ index_create(Relation heapRelation, if (OidIsValid(parentIndexRelid)) { ObjectAddressSet(referenced, RelationRelationId, parentIndexRelid); + + LockNotPinnedObject(RelationRelationId, parentIndexRelid); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, heapRelationId); + + LockNotPinnedObject(RelationRelationId, heapRelationId); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_SEC); } @@ -1175,6 +1188,7 @@ index_create(Relation heapRelation, { ObjectAddressSet(referenced, CollationRelationId, collationIds[i]); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(CollationRelationId, collationIds[i]); } } @@ -1183,6 +1197,7 @@ index_create(Relation heapRelation, { ObjectAddressSet(referenced, OperatorClassRelationId, opclassIds[i]); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(OperatorClassRelationId, opclassIds[i]); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); @@ -1987,6 +2002,14 @@ index_constraint_create(Relation heapRelation, */ ObjectAddressSet(myself, ConstraintRelationId, conOid); ObjectAddressSet(idxaddr, RelationRelationId, indexRelationId); + + /* + * CommandCounterIncrement() here to ensure the new constraint entry is + * visible when LockNotPinnedObject() will check its existence before + * recording the dependencies. + */ + CommandCounterIncrement(); + LockNotPinnedObject(ConstraintRelationId, conOid); recordDependencyOn(&idxaddr, &myself, DEPENDENCY_INTERNAL); /* @@ -1998,9 +2021,12 @@ index_constraint_create(Relation heapRelation, ObjectAddress referenced; ObjectAddressSet(referenced, ConstraintRelationId, parentConstraintId); + LockNotPinnedObject(ConstraintRelationId, parentConstraintId); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, RelationGetRelid(heapRelation)); + + LockNotPinnedObject(RelationRelationId, RelationGetRelid(heapRelation)); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_SEC); } diff --git a/src/backend/catalog/objectaddress.c b/src/backend/catalog/objectaddress.c index 7b536ac6fd..6d7abd3738 100644 --- a/src/backend/catalog/objectaddress.c +++ b/src/backend/catalog/objectaddress.c @@ -2590,6 +2590,63 @@ get_object_namespace(const ObjectAddress *address) return oid; } +/* + * ObjectByIdExist + * + * Return whether the given object exists. + * + * Works for most catalogs, if no special processing is needed. + */ +bool +ObjectByIdExist(const ObjectAddress *address) +{ + HeapTuple tuple; + int cache; + const ObjectPropertyType *property; + + property = get_object_property_data(address->classId); + + cache = property->oid_catcache_id; + + if (cache >= 0) + { + /* Fetch tuple from syscache. */ + tuple = SearchSysCache1(cache, ObjectIdGetDatum(address->objectId)); + + if (!HeapTupleIsValid(tuple)) + { + return false; + } + + ReleaseSysCache(tuple); + + return true; + } + else + { + Relation rel; + ScanKeyData skey[1]; + SysScanDesc scan; + + rel = table_open(address->classId, AccessShareLock); + + ScanKeyInit(&skey[0], + get_object_attnum_oid(address->classId), + BTEqualStrategyNumber, F_OIDEQ, + ObjectIdGetDatum(address->objectId)); + + scan = systable_beginscan(rel, get_object_oid_index(address->classId), true, + NULL, 1, skey); + + /* we expect exactly one match */ + tuple = systable_getnext(scan); + systable_endscan(scan); + table_close(rel, AccessShareLock); + + return (HeapTupleIsValid(tuple)); + } +} + /* * Return ObjectType for the given object type as given by * getObjectTypeDescription; if no valid ObjectType code exists, but it's a diff --git a/src/backend/catalog/pg_aggregate.c b/src/backend/catalog/pg_aggregate.c index 90fc7db949..a47e3c5507 100644 --- a/src/backend/catalog/pg_aggregate.c +++ b/src/backend/catalog/pg_aggregate.c @@ -748,12 +748,14 @@ AggregateCreate(const char *aggName, /* Depends on transition function */ ObjectAddressSet(referenced, ProcedureRelationId, transfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, transfn); /* Depends on final function, if any */ if (OidIsValid(finalfn)) { ObjectAddressSet(referenced, ProcedureRelationId, finalfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, finalfn); } /* Depends on combine function, if any */ @@ -761,6 +763,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, combinefn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, combinefn); } /* Depends on serialization function, if any */ @@ -768,6 +771,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, serialfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, serialfn); } /* Depends on deserialization function, if any */ @@ -775,6 +779,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, deserialfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, deserialfn); } /* Depends on forward transition function, if any */ @@ -782,6 +787,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, mtransfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, mtransfn); } /* Depends on inverse transition function, if any */ @@ -789,6 +795,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, minvtransfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, minvtransfn); } /* Depends on final function, if any */ @@ -796,6 +803,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, ProcedureRelationId, mfinalfn); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, mfinalfn); } /* Depends on sort operator, if any */ @@ -803,6 +811,7 @@ AggregateCreate(const char *aggName, { ObjectAddressSet(referenced, OperatorRelationId, sortop); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(OperatorRelationId, sortop); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); diff --git a/src/backend/catalog/pg_attrdef.c b/src/backend/catalog/pg_attrdef.c index 003ae70b4d..dcce454f00 100644 --- a/src/backend/catalog/pg_attrdef.c +++ b/src/backend/catalog/pg_attrdef.c @@ -178,6 +178,7 @@ StoreAttrDefault(Relation rel, AttrNumber attnum, colobject.objectId = RelationGetRelid(rel); colobject.objectSubId = attnum; + LockNotPinnedObject(RelationRelationId, RelationGetRelid(rel)); recordDependencyOn(&defobject, &colobject, attgenerated ? DEPENDENCY_INTERNAL : DEPENDENCY_AUTO); diff --git a/src/backend/catalog/pg_cast.c b/src/backend/catalog/pg_cast.c index 5a5b855d51..d3707e424c 100644 --- a/src/backend/catalog/pg_cast.c +++ b/src/backend/catalog/pg_cast.c @@ -97,16 +97,19 @@ CastCreate(Oid sourcetypeid, Oid targettypeid, /* dependency on source type */ ObjectAddressSet(referenced, TypeRelationId, sourcetypeid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, sourcetypeid); /* dependency on target type */ ObjectAddressSet(referenced, TypeRelationId, targettypeid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, targettypeid); /* dependency on function */ if (OidIsValid(funcid)) { ObjectAddressSet(referenced, ProcedureRelationId, funcid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, funcid); } /* dependencies on casts required for function */ @@ -114,11 +117,13 @@ CastCreate(Oid sourcetypeid, Oid targettypeid, { ObjectAddressSet(referenced, CastRelationId, incastid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(CastRelationId, incastid); } if (OidIsValid(outcastid)) { ObjectAddressSet(referenced, CastRelationId, outcastid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(CastRelationId, outcastid); } record_object_address_dependencies(&myself, addrs, behavior); diff --git a/src/backend/catalog/pg_collation.c b/src/backend/catalog/pg_collation.c index 7f2f701229..78498b8c20 100644 --- a/src/backend/catalog/pg_collation.c +++ b/src/backend/catalog/pg_collation.c @@ -218,6 +218,7 @@ CollationCreate(const char *collname, Oid collnamespace, referenced.classId = NamespaceRelationId; referenced.objectId = collnamespace; referenced.objectSubId = 0; + LockNotPinnedObject(NamespaceRelationId, collnamespace); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* create dependency on owner */ diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c index 3baf9231ed..c4cdbd7c58 100644 --- a/src/backend/catalog/pg_constraint.c +++ b/src/backend/catalog/pg_constraint.c @@ -252,17 +252,26 @@ CreateConstraintEntry(const char *constraintName, if (constraintNTotalKeys > 0) { + bool locked_object = false; + for (i = 0; i < constraintNTotalKeys; i++) { ObjectAddressSubSet(relobject, RelationRelationId, relId, constraintKey[i]); add_exact_object_address(&relobject, addrs_auto); + + if (!locked_object) + { + LockNotPinnedObject(RelationRelationId, relId); + locked_object = true; + } } } else { ObjectAddressSet(relobject, RelationRelationId, relId); add_exact_object_address(&relobject, addrs_auto); + LockNotPinnedObject(RelationRelationId, relId); } } @@ -275,6 +284,7 @@ CreateConstraintEntry(const char *constraintName, ObjectAddressSet(domobject, TypeRelationId, domainId); add_exact_object_address(&domobject, addrs_auto); + LockNotPinnedObject(TypeRelationId, domainId); } record_object_address_dependencies(&conobject, addrs_auto, @@ -294,17 +304,26 @@ CreateConstraintEntry(const char *constraintName, if (foreignNKeys > 0) { + bool locked_object = false; + for (i = 0; i < foreignNKeys; i++) { ObjectAddressSubSet(relobject, RelationRelationId, foreignRelId, foreignKey[i]); add_exact_object_address(&relobject, addrs_normal); + + if (!locked_object) + { + LockNotPinnedObject(RelationRelationId, foreignRelId); + locked_object = true; + } } } else { ObjectAddressSet(relobject, RelationRelationId, foreignRelId); add_exact_object_address(&relobject, addrs_normal); + LockNotPinnedObject(RelationRelationId, foreignRelId); } } @@ -320,6 +339,7 @@ CreateConstraintEntry(const char *constraintName, ObjectAddressSet(relobject, RelationRelationId, indexRelId); add_exact_object_address(&relobject, addrs_normal); + LockNotPinnedObject(RelationRelationId, indexRelId); } if (foreignNKeys > 0) @@ -339,15 +359,18 @@ CreateConstraintEntry(const char *constraintName, { oprobject.objectId = pfEqOp[i]; add_exact_object_address(&oprobject, addrs_normal); + LockNotPinnedObject(OperatorRelationId, pfEqOp[i]); if (ppEqOp[i] != pfEqOp[i]) { oprobject.objectId = ppEqOp[i]; add_exact_object_address(&oprobject, addrs_normal); + LockNotPinnedObject(OperatorRelationId, ppEqOp[i]); } if (ffEqOp[i] != pfEqOp[i]) { oprobject.objectId = ffEqOp[i]; add_exact_object_address(&oprobject, addrs_normal); + LockNotPinnedObject(OperatorRelationId, ffEqOp[i]); } } } @@ -858,9 +881,12 @@ ConstraintSetParentConstraint(Oid childConstrId, ObjectAddressSet(depender, ConstraintRelationId, childConstrId); ObjectAddressSet(referenced, ConstraintRelationId, parentConstrId); + LockNotPinnedObject(ConstraintRelationId, parentConstrId); recordDependencyOn(&depender, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, childTableId); + + LockNotPinnedObject(RelationRelationId, childTableId); recordDependencyOn(&depender, &referenced, DEPENDENCY_PARTITION_SEC); } else diff --git a/src/backend/catalog/pg_conversion.c b/src/backend/catalog/pg_conversion.c index 0770878eac..25881654d6 100644 --- a/src/backend/catalog/pg_conversion.c +++ b/src/backend/catalog/pg_conversion.c @@ -116,12 +116,14 @@ ConversionCreate(const char *conname, Oid connamespace, referenced.classId = ProcedureRelationId; referenced.objectId = conproc; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, conproc); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* create dependency on namespace */ referenced.classId = NamespaceRelationId; referenced.objectId = connamespace; referenced.objectSubId = 0; + LockNotPinnedObject(NamespaceRelationId, connamespace); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* create dependency on owner */ diff --git a/src/backend/catalog/pg_depend.c b/src/backend/catalog/pg_depend.c index cfd7ef51df..538b6c3c2c 100644 --- a/src/backend/catalog/pg_depend.c +++ b/src/backend/catalog/pg_depend.c @@ -20,21 +20,20 @@ #include "catalog/catalog.h" #include "catalog/dependency.h" #include "catalog/indexing.h" +#include "catalog/pg_auth_members.h" #include "catalog/pg_constraint.h" #include "catalog/pg_depend.h" #include "catalog/pg_extension.h" #include "catalog/partition.h" #include "commands/extension.h" #include "miscadmin.h" +#include "storage/lock.h" #include "utils/fmgroids.h" #include "utils/lsyscache.h" #include "utils/syscache.h" #include "utils/rel.h" -static bool isObjectPinned(const ObjectAddress *object); - - /* * Record a dependency between 2 objects via their respective objectAddress. * The first argument is the dependent object, the second the one it @@ -100,6 +99,34 @@ recordMultipleDependencies(const ObjectAddress *depender, slot_init_count = 0; for (i = 0; i < nreferenced; i++, referenced++) { +#ifdef USE_ASSERT_CHECKING + LOCKTAG tag; + + if (!isObjectPinned(referenced) && ObjectByIdExist(referenced)) + { + if (referenced->classId != RelationRelationId) + SET_LOCKTAG_OBJECT(tag, + MyDatabaseId, + referenced->classId, + referenced->objectId, + 0); + else + { + Assert(!IsSharedRelation(referenced->objectId)); + + SET_LOCKTAG_RELATION(tag, MyDatabaseId, referenced->objectId); + } + } + + /* + * Assert the referenced object is locked if it should be visible (see + * the comment related to LockNotPinnedObject() in TypeCreate()) and + * if not pinned. + */ + Assert(!ObjectByIdExist(referenced) || isObjectPinned(referenced) || + ObjectIsLocked(&tag)); +#endif + /* * If the referenced object is pinned by the system, there's no real * need to record dependencies on it. This saves lots of space in @@ -239,6 +266,7 @@ recordDependencyOnCurrentExtension(const ObjectAddress *object, extension.objectId = CurrentExtensionObject; extension.objectSubId = 0; + LockNotPinnedObject(ExtensionRelationId, CurrentExtensionObject); recordDependencyOn(object, &extension, DEPENDENCY_EXTENSION); } } @@ -706,7 +734,7 @@ changeDependenciesOn(Oid refClassId, Oid oldRefObjectId, * The passed subId, if any, is ignored; we assume that only whole objects * are pinned (and that this implies pinning their components). */ -static bool +bool isObjectPinned(const ObjectAddress *object) { return IsPinnedObject(object->classId, object->objectId); diff --git a/src/backend/catalog/pg_operator.c b/src/backend/catalog/pg_operator.c index 65b45a424a..e8374eec88 100644 --- a/src/backend/catalog/pg_operator.c +++ b/src/backend/catalog/pg_operator.c @@ -251,6 +251,16 @@ OperatorShellMake(const char *operatorName, values[Anum_pg_operator_oprrest - 1] = ObjectIdGetDatum(InvalidOid); values[Anum_pg_operator_oprjoin - 1] = ObjectIdGetDatum(InvalidOid); + /* Lock dependent objects */ + if (OidIsValid(operatorNamespace)) + LockNotPinnedObject(NamespaceRelationId, operatorNamespace); + + if (OidIsValid(leftTypeId)) + LockNotPinnedObject(TypeRelationId, leftTypeId); + + if (OidIsValid(rightTypeId)) + LockNotPinnedObject(TypeRelationId, rightTypeId); + /* * create a new operator tuple */ @@ -513,6 +523,15 @@ OperatorCreate(const char *operatorName, CatalogTupleInsert(pg_operator_desc, tup); } + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, operatorNamespace); + LockNotPinnedObject(TypeRelationId, leftTypeId); + LockNotPinnedObject(TypeRelationId, rightTypeId); + LockNotPinnedObject(TypeRelationId, operResultType); + LockNotPinnedObject(ProcedureRelationId, procedureId); + LockNotPinnedObject(ProcedureRelationId, restrictionId); + LockNotPinnedObject(ProcedureRelationId, joinId); + /* Add dependencies for the entry */ address = makeOperatorDependencies(tup, true, isUpdate); diff --git a/src/backend/catalog/pg_proc.c b/src/backend/catalog/pg_proc.c index 528c17cd7f..116e524390 100644 --- a/src/backend/catalog/pg_proc.c +++ b/src/backend/catalog/pg_proc.c @@ -593,6 +593,13 @@ ProcedureCreate(const char *procedureName, if (is_update) deleteDependencyRecordsFor(ProcedureRelationId, retval, true); + /* + * CommandCounterIncrement() here to ensure the new function entry is + * visible when LockNotPinnedObject() will check its existence before + * recording the dependencies. + */ + CommandCounterIncrement(); + addrs = new_object_addresses(); ObjectAddressSet(myself, ProcedureRelationId, retval); @@ -600,20 +607,24 @@ ProcedureCreate(const char *procedureName, /* dependency on namespace */ ObjectAddressSet(referenced, NamespaceRelationId, procNamespace); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(NamespaceRelationId, procNamespace); /* dependency on implementation language */ ObjectAddressSet(referenced, LanguageRelationId, languageObjectId); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(LanguageRelationId, languageObjectId); /* dependency on return type */ ObjectAddressSet(referenced, TypeRelationId, returnType); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, returnType); /* dependency on transform used by return type, if any */ if ((trfid = get_transform_oid(returnType, languageObjectId, true))) { ObjectAddressSet(referenced, TransformRelationId, trfid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TransformRelationId, trfid); } /* dependency on parameter types */ @@ -621,12 +632,14 @@ ProcedureCreate(const char *procedureName, { ObjectAddressSet(referenced, TypeRelationId, allParams[i]); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, allParams[i]); /* dependency on transform used by parameter type, if any */ if ((trfid = get_transform_oid(allParams[i], languageObjectId, true))) { ObjectAddressSet(referenced, TransformRelationId, trfid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TransformRelationId, trfid); } } @@ -635,6 +648,7 @@ ProcedureCreate(const char *procedureName, { ObjectAddressSet(referenced, ProcedureRelationId, prosupport); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, prosupport); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); @@ -674,9 +688,6 @@ ProcedureCreate(const char *procedureName, ArrayType *set_items = NULL; int save_nestlevel = 0; - /* Advance command counter so new tuple can be seen by validator */ - CommandCounterIncrement(); - /* * Set per-function configuration parameters so that the validation is * done with the environment the function expects. However, if diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c index 0602398a54..b44a7f9d78 100644 --- a/src/backend/catalog/pg_publication.c +++ b/src/backend/catalog/pg_publication.c @@ -438,10 +438,13 @@ publication_add_relation(Oid pubid, PublicationRelInfo *pri, /* Add dependency on the publication */ ObjectAddressSet(referenced, PublicationRelationId, pubid); + LockNotPinnedObject(PublicationRelationId, pubid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* Add dependency on the relation */ ObjectAddressSet(referenced, RelationRelationId, relid); + + LockNotPinnedObject(RelationRelationId, relid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* Add dependency on the objects mentioned in the qualifications */ @@ -454,6 +457,8 @@ publication_add_relation(Oid pubid, PublicationRelInfo *pri, for (int i = 0; i < natts; i++) { ObjectAddressSubSet(referenced, RelationRelationId, relid, attarray[i]); + + LockNotPinnedObject(RelationRelationId, relid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } @@ -661,10 +666,12 @@ publication_add_schema(Oid pubid, Oid schemaid, bool if_not_exists) /* Add dependency on the publication */ ObjectAddressSet(referenced, PublicationRelationId, pubid); + LockNotPinnedObject(PublicationRelationId, pubid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* Add dependency on the schema */ ObjectAddressSet(referenced, NamespaceRelationId, schemaid); + LockNotPinnedObject(NamespaceRelationId, schemaid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* Close the table */ diff --git a/src/backend/catalog/pg_range.c b/src/backend/catalog/pg_range.c index 501a6ba410..e5b5a0b6f8 100644 --- a/src/backend/catalog/pg_range.c +++ b/src/backend/catalog/pg_range.c @@ -70,26 +70,31 @@ RangeCreate(Oid rangeTypeOid, Oid rangeSubType, Oid rangeCollation, ObjectAddressSet(referenced, TypeRelationId, rangeSubType); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, rangeSubType); ObjectAddressSet(referenced, OperatorClassRelationId, rangeSubOpclass); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(OperatorClassRelationId, rangeSubOpclass); if (OidIsValid(rangeCollation)) { ObjectAddressSet(referenced, CollationRelationId, rangeCollation); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(CollationRelationId, rangeCollation); } if (OidIsValid(rangeCanonical)) { ObjectAddressSet(referenced, ProcedureRelationId, rangeCanonical); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, rangeCanonical); } if (OidIsValid(rangeSubDiff)) { ObjectAddressSet(referenced, ProcedureRelationId, rangeSubDiff); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, rangeSubDiff); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); @@ -99,6 +104,7 @@ RangeCreate(Oid rangeTypeOid, Oid rangeSubType, Oid rangeCollation, referencing.classId = TypeRelationId; referencing.objectId = multirangeTypeOid; referencing.objectSubId = 0; + LockNotPinnedObject(TypeRelationId, rangeTypeOid); recordDependencyOn(&referencing, &myself, DEPENDENCY_INTERNAL); table_close(pg_range, RowExclusiveLock); diff --git a/src/backend/catalog/pg_type.c b/src/backend/catalog/pg_type.c index 395dec8ed8..82ee7bc2e3 100644 --- a/src/backend/catalog/pg_type.c +++ b/src/backend/catalog/pg_type.c @@ -157,6 +157,12 @@ TypeShellMake(const char *typeName, Oid typeNamespace, Oid ownerId) * Create dependencies. We can/must skip this in bootstrap mode. */ if (!IsBootstrapProcessingMode()) + { + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, typeNamespace); + LockNotPinnedObject(ProcedureRelationId, F_SHELL_IN); + LockNotPinnedObject(ProcedureRelationId, F_SHELL_OUT); + GenerateTypeDependencies(tup, pg_type_desc, NULL, @@ -166,6 +172,7 @@ TypeShellMake(const char *typeName, Oid typeNamespace, Oid ownerId) false, true, /* make extension dependency */ false); + } /* Post creation hook for new shell type */ InvokeObjectPostCreateHook(TypeRelationId, typoid, 0); @@ -494,6 +501,37 @@ TypeCreate(Oid newTypeOid, * Create dependencies. We can/must skip this in bootstrap mode. */ if (!IsBootstrapProcessingMode()) + { + /* + * CommandCounterIncrement() here to ensure the new type entry is + * visible when LockNotPinnedObject() will check its existence before + * recording the dependencies. + */ + CommandCounterIncrement(); + + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, typeNamespace); + LockNotPinnedObject(ProcedureRelationId, inputProcedure); + LockNotPinnedObject(ProcedureRelationId, outputProcedure); + LockNotPinnedObject(ProcedureRelationId, receiveProcedure); + LockNotPinnedObject(ProcedureRelationId, sendProcedure); + LockNotPinnedObject(ProcedureRelationId, typmodinProcedure); + LockNotPinnedObject(ProcedureRelationId, typmodoutProcedure); + LockNotPinnedObject(ProcedureRelationId, analyzeProcedure); + LockNotPinnedObject(ProcedureRelationId, subscriptProcedure); + LockNotPinnedObject(TypeRelationId, baseType); + LockNotPinnedObject(CollationRelationId, typeCollation); + LockNotPinnedObject(TypeRelationId, elementType); + + /* + * No need to call LockRelationOid() (through LockNotPinnedObject()) + * on relationOid as relationOid is set to an InvalidOid or to a new + * Oid not added to pg_class yet (In heap_create_with_catalog(), + * AddNewRelationType() is called before AddNewRelationTuple()). + */ + if (relationKind == RELKIND_COMPOSITE_TYPE) + LockNotPinnedObject(TypeRelationId, typeObjectId); + GenerateTypeDependencies(tup, pg_type_desc, (defaultTypeBin ? @@ -505,6 +543,7 @@ TypeCreate(Oid newTypeOid, isDependentType, true, /* make extension dependency */ rebuildDeps); + } /* Post creation hook for new type */ InvokeObjectPostCreateHook(TypeRelationId, typeObjectId, 0); diff --git a/src/backend/catalog/toasting.c b/src/backend/catalog/toasting.c index 738bc46ae8..a4d8342ca1 100644 --- a/src/backend/catalog/toasting.c +++ b/src/backend/catalog/toasting.c @@ -367,6 +367,7 @@ create_toast_table(Relation rel, Oid toastOid, Oid toastIndexOid, toastobject.objectId = toast_relid; toastobject.objectSubId = 0; + LockNotPinnedObject(RelationRelationId, relOid); recordDependencyOn(&toastobject, &baseobject, DEPENDENCY_INTERNAL); } diff --git a/src/backend/commands/alter.c b/src/backend/commands/alter.c index 4f99ebb447..57e86f576a 100644 --- a/src/backend/commands/alter.c +++ b/src/backend/commands/alter.c @@ -501,7 +501,10 @@ ExecAlterObjectDependsStmt(AlterObjectDependsStmt *stmt, ObjectAddress *refAddre currexts = getAutoExtensionsOfObject(address.classId, address.objectId); if (!list_member_oid(currexts, refAddr.objectId)) + { + LockNotPinnedObject(refAddr.classId, refAddr.objectId); recordDependencyOn(&address, &refAddr, DEPENDENCY_AUTO_EXTENSION); + } } return address; @@ -807,6 +810,7 @@ AlterObjectNamespace_internal(Relation rel, Oid objid, Oid nspOid) pfree(replaces); /* update dependency to point to the new schema */ + LockNotPinnedObject(NamespaceRelationId, nspOid); if (changeDependencyFor(classId, objid, NamespaceRelationId, oldNspOid, nspOid) != 1) elog(ERROR, "could not change schema dependency for object %u", diff --git a/src/backend/commands/amcmds.c b/src/backend/commands/amcmds.c index aaa0f9a1dc..8616a7c9fa 100644 --- a/src/backend/commands/amcmds.c +++ b/src/backend/commands/amcmds.c @@ -104,6 +104,7 @@ CreateAccessMethod(CreateAmStmt *stmt) referenced.objectId = amhandler; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, amhandler); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); recordDependencyOnCurrentExtension(&myself, false); diff --git a/src/backend/commands/cluster.c b/src/backend/commands/cluster.c index 78f96789b0..fb95d17738 100644 --- a/src/backend/commands/cluster.c +++ b/src/backend/commands/cluster.c @@ -1272,6 +1272,7 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, */ if (relam1 != relam2) { + LockNotPinnedObject(AccessMethodRelationId, relam2); if (changeDependencyFor(RelationRelationId, r1, AccessMethodRelationId, @@ -1280,6 +1281,8 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, elog(ERROR, "could not change access method dependency for relation \"%s.%s\"", get_namespace_name(get_rel_namespace(r1)), get_rel_name(r1)); + + LockNotPinnedObject(AccessMethodRelationId, relam1); if (changeDependencyFor(RelationRelationId, r2, AccessMethodRelationId, @@ -1381,6 +1384,8 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, { baseobject.objectId = r1; toastobject.objectId = relform1->reltoastrelid; + + LockNotPinnedObject(RelationRelationId, r1); recordDependencyOn(&toastobject, &baseobject, DEPENDENCY_INTERNAL); } @@ -1389,6 +1394,8 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, { baseobject.objectId = r2; toastobject.objectId = relform2->reltoastrelid; + + LockNotPinnedObject(RelationRelationId, r2); recordDependencyOn(&toastobject, &baseobject, DEPENDENCY_INTERNAL); } diff --git a/src/backend/commands/event_trigger.c b/src/backend/commands/event_trigger.c index 7a5ed6b985..8d0cdec59e 100644 --- a/src/backend/commands/event_trigger.c +++ b/src/backend/commands/event_trigger.c @@ -327,6 +327,7 @@ insert_event_trigger_tuple(const char *trigname, const char *eventname, Oid evtO referenced.classId = ProcedureRelationId; referenced.objectId = funcoid; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, funcoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* Depend on extension, if any. */ diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c index 1643c8c69a..669a5d6dd8 100644 --- a/src/backend/commands/extension.c +++ b/src/backend/commands/extension.c @@ -1924,6 +1924,7 @@ InsertExtensionTuple(const char *extName, Oid extOwner, ObjectAddressSet(nsp, NamespaceRelationId, schemaOid); add_exact_object_address(&nsp, refobjs); + LockNotPinnedObject(NamespaceRelationId, schemaOid); foreach(lc, requiredExtensions) { @@ -1932,6 +1933,7 @@ InsertExtensionTuple(const char *extName, Oid extOwner, ObjectAddressSet(otherext, ExtensionRelationId, reqext); add_exact_object_address(&otherext, refobjs); + LockNotPinnedObject(ExtensionRelationId, reqext); } /* Record all of them (this includes duplicate elimination) */ @@ -2968,6 +2970,7 @@ AlterExtensionNamespace(const char *extensionName, const char *newschema, Oid *o table_close(extRel, RowExclusiveLock); /* update dependency to point to the new schema */ + LockNotPinnedObject(NamespaceRelationId, nspOid); if (changeDependencyFor(ExtensionRelationId, extensionOid, NamespaceRelationId, oldNspOid, nspOid) != 1) elog(ERROR, "could not change schema dependency for extension %s", @@ -3258,6 +3261,7 @@ ApplyExtensionUpdates(Oid extensionOid, otherext.objectId = reqext; otherext.objectSubId = 0; + LockNotPinnedObject(ExtensionRelationId, reqext); recordDependencyOn(&myself, &otherext, DEPENDENCY_NORMAL); } @@ -3414,6 +3418,7 @@ ExecAlterExtensionContentsRecurse(AlterExtensionContentsStmt *stmt, /* * OK, add the dependency. */ + LockNotPinnedObject(extension.classId, extension.objectId); recordDependencyOn(&object, &extension, DEPENDENCY_EXTENSION); /* diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c index cf61bbac1f..735bca486c 100644 --- a/src/backend/commands/foreigncmds.c +++ b/src/backend/commands/foreigncmds.c @@ -642,6 +642,7 @@ CreateForeignDataWrapper(ParseState *pstate, CreateFdwStmt *stmt) referenced.classId = ProcedureRelationId; referenced.objectId = fdwhandler; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, fdwhandler); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } @@ -650,6 +651,7 @@ CreateForeignDataWrapper(ParseState *pstate, CreateFdwStmt *stmt) referenced.classId = ProcedureRelationId; referenced.objectId = fdwvalidator; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, fdwvalidator); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } @@ -811,6 +813,7 @@ AlterForeignDataWrapper(ParseState *pstate, AlterFdwStmt *stmt) referenced.classId = ProcedureRelationId; referenced.objectId = fdwhandler; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, fdwhandler); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } @@ -819,6 +822,7 @@ AlterForeignDataWrapper(ParseState *pstate, AlterFdwStmt *stmt) referenced.classId = ProcedureRelationId; referenced.objectId = fdwvalidator; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, fdwvalidator); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } } @@ -951,6 +955,7 @@ CreateForeignServer(CreateForeignServerStmt *stmt) referenced.classId = ForeignDataWrapperRelationId; referenced.objectId = fdw->fdwid; referenced.objectSubId = 0; + LockNotPinnedObject(ForeignDataWrapperRelationId, fdw->fdwid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); recordDependencyOnOwner(ForeignServerRelationId, srvId, ownerId); @@ -1195,6 +1200,7 @@ CreateUserMapping(CreateUserMappingStmt *stmt) referenced.classId = ForeignServerRelationId; referenced.objectId = srv->serverid; referenced.objectSubId = 0; + LockNotPinnedObject(ForeignServerRelationId, srv->serverid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); if (OidIsValid(useId)) @@ -1472,6 +1478,7 @@ CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid) referenced.classId = ForeignServerRelationId; referenced.objectId = server->serverid; referenced.objectSubId = 0; + LockNotPinnedObject(ForeignServerRelationId, server->serverid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); table_close(ftrel, RowExclusiveLock); diff --git a/src/backend/commands/functioncmds.c b/src/backend/commands/functioncmds.c index 6593fd7d81..8207ef08b3 100644 --- a/src/backend/commands/functioncmds.c +++ b/src/backend/commands/functioncmds.c @@ -1446,6 +1446,7 @@ AlterFunction(ParseState *pstate, AlterFunctionStmt *stmt) /* Add or replace dependency on support function */ if (OidIsValid(procForm->prosupport)) { + LockNotPinnedObject(ProcedureRelationId, newsupport); if (changeDependencyFor(ProcedureRelationId, funcOid, ProcedureRelationId, procForm->prosupport, newsupport) != 1) @@ -1459,6 +1460,7 @@ AlterFunction(ParseState *pstate, AlterFunctionStmt *stmt) referenced.classId = ProcedureRelationId; referenced.objectId = newsupport; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, newsupport); recordDependencyOn(&address, &referenced, DEPENDENCY_NORMAL); } @@ -1962,21 +1964,25 @@ CreateTransform(CreateTransformStmt *stmt) /* dependency on language */ ObjectAddressSet(referenced, LanguageRelationId, langid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(LanguageRelationId, langid); /* dependency on type */ ObjectAddressSet(referenced, TypeRelationId, typeid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TypeRelationId, typeid); /* dependencies on functions */ if (OidIsValid(fromsqlfuncid)) { ObjectAddressSet(referenced, ProcedureRelationId, fromsqlfuncid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, fromsqlfuncid); } if (OidIsValid(tosqlfuncid)) { ObjectAddressSet(referenced, ProcedureRelationId, tosqlfuncid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, tosqlfuncid); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c index 309389e20d..76849e558e 100644 --- a/src/backend/commands/indexcmds.c +++ b/src/backend/commands/indexcmds.c @@ -4377,8 +4377,10 @@ IndexSetParentIndex(Relation partitionIdx, Oid parentOid) ObjectAddressSet(parentIdx, RelationRelationId, parentOid); ObjectAddressSet(partitionTbl, RelationRelationId, partitionIdx->rd_index->indrelid); + LockNotPinnedObject(RelationRelationId, parentOid); recordDependencyOn(&partIdx, &parentIdx, DEPENDENCY_PARTITION_PRI); + LockNotPinnedObject(RelationRelationId, partitionIdx->rd_index->indrelid); recordDependencyOn(&partIdx, &partitionTbl, DEPENDENCY_PARTITION_SEC); } diff --git a/src/backend/commands/opclasscmds.c b/src/backend/commands/opclasscmds.c index b8b5c147c5..e70afd216c 100644 --- a/src/backend/commands/opclasscmds.c +++ b/src/backend/commands/opclasscmds.c @@ -298,12 +298,14 @@ CreateOpFamily(CreateOpFamilyStmt *stmt, const char *opfname, referenced.classId = AccessMethodRelationId; referenced.objectId = amoid; referenced.objectSubId = 0; + LockNotPinnedObject(AccessMethodRelationId, amoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* dependency on namespace */ referenced.classId = NamespaceRelationId; referenced.objectId = namespaceoid; referenced.objectSubId = 0; + LockNotPinnedObject(NamespaceRelationId, namespaceoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* dependency on owner */ @@ -725,18 +727,21 @@ DefineOpClass(CreateOpClassStmt *stmt) referenced.classId = NamespaceRelationId; referenced.objectId = namespaceoid; referenced.objectSubId = 0; + LockNotPinnedObject(NamespaceRelationId, namespaceoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* dependency on opfamily */ referenced.classId = OperatorFamilyRelationId; referenced.objectId = opfamilyoid; referenced.objectSubId = 0; + LockNotPinnedObject(OperatorFamilyRelationId, opfamilyoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); /* dependency on indexed datatype */ referenced.classId = TypeRelationId; referenced.objectId = typeoid; referenced.objectSubId = 0; + LockNotPinnedObject(TypeRelationId, typeoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); /* dependency on storage datatype */ @@ -745,6 +750,7 @@ DefineOpClass(CreateOpClassStmt *stmt) referenced.classId = TypeRelationId; referenced.objectId = storageoid; referenced.objectSubId = 0; + LockNotPinnedObject(TypeRelationId, storageoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } @@ -1486,6 +1492,13 @@ storeOperators(List *opfamilyname, Oid amoid, Oid opfamilyoid, heap_freetuple(tup); + /* + * CommandCounterIncrement() here to ensure the new operator entry is + * visible when LockNotPinnedObject() will check its existence before + * recording the dependencies. + */ + CommandCounterIncrement(); + /* Make its dependencies */ myself.classId = AccessMethodOperatorRelationId; myself.objectId = entryoid; @@ -1496,6 +1509,7 @@ storeOperators(List *opfamilyname, Oid amoid, Oid opfamilyoid, referenced.objectSubId = 0; /* see comments in amapi.h about dependency strength */ + LockNotPinnedObject(OperatorRelationId, op->object); recordDependencyOn(&myself, &referenced, op->ref_is_hard ? DEPENDENCY_NORMAL : DEPENDENCY_AUTO); @@ -1504,6 +1518,7 @@ storeOperators(List *opfamilyname, Oid amoid, Oid opfamilyoid, referenced.objectId = op->refobjid; referenced.objectSubId = 0; + LockNotPinnedObject(referenced.classId, op->refobjid); recordDependencyOn(&myself, &referenced, op->ref_is_hard ? DEPENDENCY_INTERNAL : DEPENDENCY_AUTO); @@ -1514,6 +1529,7 @@ storeOperators(List *opfamilyname, Oid amoid, Oid opfamilyoid, referenced.objectId = op->sortfamily; referenced.objectSubId = 0; + LockNotPinnedObject(OperatorFamilyRelationId, op->sortfamily); recordDependencyOn(&myself, &referenced, op->ref_is_hard ? DEPENDENCY_NORMAL : DEPENDENCY_AUTO); } @@ -1597,6 +1613,7 @@ storeProcedures(List *opfamilyname, Oid amoid, Oid opfamilyoid, referenced.objectSubId = 0; /* see comments in amapi.h about dependency strength */ + LockNotPinnedObject(ProcedureRelationId, proc->object); recordDependencyOn(&myself, &referenced, proc->ref_is_hard ? DEPENDENCY_NORMAL : DEPENDENCY_AUTO); @@ -1605,6 +1622,7 @@ storeProcedures(List *opfamilyname, Oid amoid, Oid opfamilyoid, referenced.objectId = proc->refobjid; referenced.objectSubId = 0; + LockNotPinnedObject(referenced.classId, proc->refobjid); recordDependencyOn(&myself, &referenced, proc->ref_is_hard ? DEPENDENCY_INTERNAL : DEPENDENCY_AUTO); diff --git a/src/backend/commands/operatorcmds.c b/src/backend/commands/operatorcmds.c index 5872a3e192..58a69e7cc2 100644 --- a/src/backend/commands/operatorcmds.c +++ b/src/backend/commands/operatorcmds.c @@ -33,6 +33,7 @@ #include "access/htup_details.h" #include "access/table.h" +#include "catalog/dependency.h" #include "catalog/indexing.h" #include "catalog/objectaccess.h" #include "catalog/pg_namespace.h" @@ -656,11 +657,15 @@ AlterOperator(AlterOperatorStmt *stmt) { replaces[Anum_pg_operator_oprrest - 1] = true; values[Anum_pg_operator_oprrest - 1] = ObjectIdGetDatum(restrictionOid); + if (OidIsValid(restrictionOid)) + LockNotPinnedObject(ProcedureRelationId, restrictionOid); } if (updateJoin) { replaces[Anum_pg_operator_oprjoin - 1] = true; values[Anum_pg_operator_oprjoin - 1] = ObjectIdGetDatum(joinOid); + if (OidIsValid(joinOid)) + LockNotPinnedObject(ProcedureRelationId, joinOid); } if (OidIsValid(commutatorOid)) { @@ -688,6 +693,31 @@ AlterOperator(AlterOperatorStmt *stmt) CatalogTupleUpdate(catalog, &tup->t_self, tup); + + /* Lock dependent objects */ + oprForm = (Form_pg_operator) GETSTRUCT(tup); + + if (OidIsValid(oprForm->oprnamespace)) + LockNotPinnedObject(NamespaceRelationId, oprForm->oprnamespace); + + if (OidIsValid(oprForm->oprleft)) + LockNotPinnedObject(TypeRelationId, oprForm->oprleft); + + if (OidIsValid(oprForm->oprright)) + LockNotPinnedObject(TypeRelationId, oprForm->oprright); + + if (OidIsValid(oprForm->oprresult)) + LockNotPinnedObject(TypeRelationId, oprForm->oprresult); + + if (OidIsValid(oprForm->oprcode)) + LockNotPinnedObject(ProcedureRelationId, oprForm->oprcode); + + if (OidIsValid(oprForm->oprrest)) + LockNotPinnedObject(ProcedureRelationId, oprForm->oprrest); + + if (OidIsValid(oprForm->oprjoin)) + LockNotPinnedObject(ProcedureRelationId, oprForm->oprjoin); + address = makeOperatorDependencies(tup, false, true); if (OidIsValid(commutatorOid) || OidIsValid(negatorOid)) diff --git a/src/backend/commands/policy.c b/src/backend/commands/policy.c index 6ff3eba824..9da98cbeec 100644 --- a/src/backend/commands/policy.c +++ b/src/backend/commands/policy.c @@ -722,6 +722,7 @@ CreatePolicy(CreatePolicyStmt *stmt) myself.objectId = policy_id; myself.objectSubId = 0; + LockNotPinnedObject(RelationRelationId, table_id); recordDependencyOn(&myself, &target, DEPENDENCY_AUTO); recordDependencyOnExpr(&myself, qual, qual_pstate->p_rtable, @@ -1053,6 +1054,7 @@ AlterPolicy(AlterPolicyStmt *stmt) myself.objectId = policy_id; myself.objectSubId = 0; + LockNotPinnedObject(RelationRelationId, table_id); recordDependencyOn(&myself, &target, DEPENDENCY_AUTO); recordDependencyOnExpr(&myself, qual, qual_parse_rtable, DEPENDENCY_NORMAL); diff --git a/src/backend/commands/proclang.c b/src/backend/commands/proclang.c index 881f90017e..fadfd9064f 100644 --- a/src/backend/commands/proclang.c +++ b/src/backend/commands/proclang.c @@ -190,12 +190,14 @@ CreateProceduralLanguage(CreatePLangStmt *stmt) /* dependency on the PL handler function */ ObjectAddressSet(referenced, ProcedureRelationId, handlerOid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, handlerOid); /* dependency on the inline handler function, if any */ if (OidIsValid(inlineOid)) { ObjectAddressSet(referenced, ProcedureRelationId, inlineOid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, inlineOid); } /* dependency on the validator function, if any */ @@ -203,6 +205,7 @@ CreateProceduralLanguage(CreatePLangStmt *stmt) { ObjectAddressSet(referenced, ProcedureRelationId, valOid); add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(ProcedureRelationId, valOid); } record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL); diff --git a/src/backend/commands/sequence.c b/src/backend/commands/sequence.c index 28f8522264..7fbebf2052 100644 --- a/src/backend/commands/sequence.c +++ b/src/backend/commands/sequence.c @@ -1681,6 +1681,8 @@ process_owned_by(Relation seqrel, List *owned_by, bool for_identity) depobject.classId = RelationRelationId; depobject.objectId = RelationGetRelid(seqrel); depobject.objectSubId = 0; + + LockNotPinnedObject(RelationRelationId, RelationGetRelid(tablerel)); recordDependencyOn(&depobject, &refobject, deptype); } diff --git a/src/backend/commands/statscmds.c b/src/backend/commands/statscmds.c index 1db3ef69d2..9f0b03388a 100644 --- a/src/backend/commands/statscmds.c +++ b/src/backend/commands/statscmds.c @@ -88,6 +88,7 @@ CreateStatistics(CreateStatsStmt *stmt) bool build_mcv; bool build_expressions; bool requested_type = false; + bool locked_object = false; int i; ListCell *cell; ListCell *cell2; @@ -536,6 +537,12 @@ CreateStatistics(CreateStatsStmt *stmt) for (i = 0; i < nattnums; i++) { ObjectAddressSubSet(parentobject, RelationRelationId, relid, attnums[i]); + + if (!locked_object) + { + LockNotPinnedObject(RelationRelationId, relid); + locked_object = true; + } recordDependencyOn(&myself, &parentobject, DEPENDENCY_AUTO); } @@ -553,6 +560,8 @@ CreateStatistics(CreateStatsStmt *stmt) if (!nattnums) { ObjectAddressSet(parentobject, RelationRelationId, relid); + + LockNotPinnedObject(RelationRelationId, relid); recordDependencyOn(&myself, &parentobject, DEPENDENCY_AUTO); } @@ -573,6 +582,7 @@ CreateStatistics(CreateStatsStmt *stmt) * than the underlying table(s). */ ObjectAddressSet(parentobject, NamespaceRelationId, namespaceId); + LockNotPinnedObject(NamespaceRelationId, namespaceId); recordDependencyOn(&myself, &parentobject, DEPENDENCY_NORMAL); recordDependencyOnOwner(StatisticExtRelationId, statoid, stxowner); diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 66cda26a25..117259fba7 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -3438,6 +3438,7 @@ StoreCatalogInheritance1(Oid relationId, Oid parentOid, childobject.objectId = relationId; childobject.objectSubId = 0; + LockNotPinnedObject(RelationRelationId, parentOid); recordDependencyOn(&childobject, &parentobject, child_dependency_type(child_is_partition)); @@ -7349,7 +7350,9 @@ ATExecAddColumn(List **wqueue, AlteredTableInfo *tab, Relation rel, /* * Add needed dependency entries for the new column. */ + LockNotPinnedObject(TypeRelationId, attribute->atttypid); add_column_datatype_dependency(myrelid, newattnum, attribute->atttypid); + LockNotPinnedObject(CollationRelationId, attribute->attcollation); add_column_collation_dependency(myrelid, newattnum, attribute->attcollation); /* @@ -10174,6 +10177,7 @@ addFkRecurseReferenced(List **wqueue, Constraint *fkconstraint, Relation rel, ObjectAddress referenced; ObjectAddressSet(referenced, ConstraintRelationId, parentConstr); + LockNotPinnedObject(ConstraintRelationId, parentConstr); recordDependencyOn(&address, &referenced, DEPENDENCY_INTERNAL); } @@ -10465,8 +10469,11 @@ addFkRecurseReferencing(List **wqueue, Constraint *fkconstraint, Relation rel, */ ObjectAddressSet(address, ConstraintRelationId, constrOid); ObjectAddressSet(referenced, ConstraintRelationId, parentConstr); + LockNotPinnedObject(ConstraintRelationId, parentConstr); recordDependencyOn(&address, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, partitionId); + + LockNotPinnedObject(RelationRelationId, partitionId); recordDependencyOn(&address, &referenced, DEPENDENCY_PARTITION_SEC); /* Make all this visible before recursing */ @@ -10967,9 +10974,12 @@ CloneFkReferencing(List **wqueue, Relation parentRel, Relation partRel) /* Set up partition dependencies for the new constraint */ ObjectAddressSet(address, ConstraintRelationId, constrOid); ObjectAddressSet(referenced, ConstraintRelationId, parentConstrOid); + LockDatabaseObject(ConstraintRelationId, parentConstrOid, 0, AccessShareLock); recordDependencyOn(&address, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, RelationGetRelid(partRel)); + + LockNotPinnedObject(RelationRelationId, RelationGetRelid(partRel)); recordDependencyOn(&address, &referenced, DEPENDENCY_PARTITION_SEC); /* Done with the cloned constraint's tuple */ @@ -13254,7 +13264,9 @@ ATExecAlterColumnType(AlteredTableInfo *tab, Relation rel, table_close(attrelation, RowExclusiveLock); /* Install dependencies on new datatype and collation */ + LockNotPinnedObject(TypeRelationId, targettype); add_column_datatype_dependency(RelationGetRelid(rel), attnum, targettype); + LockNotPinnedObject(CollationRelationId, targetcollid); add_column_collation_dependency(RelationGetRelid(rel), attnum, targetcollid); /* @@ -14816,6 +14828,7 @@ ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId) */ ObjectAddressSet(relobj, RelationRelationId, reloid); ObjectAddressSet(referenced, AccessMethodRelationId, rd_rel->relam); + LockNotPinnedObject(AccessMethodRelationId, rd_rel->relam); recordDependencyOn(&relobj, &referenced, DEPENDENCY_NORMAL); } else if (OidIsValid(oldAccessMethodId) && @@ -14835,6 +14848,7 @@ ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId) OidIsValid(rd_rel->relam)); /* Both are valid, so update the dependency */ + LockNotPinnedObject(AccessMethodRelationId, rd_rel->relam); changeDependencyFor(RelationRelationId, reloid, AccessMethodRelationId, oldAccessMethodId, rd_rel->relam); @@ -16434,6 +16448,7 @@ ATExecAddOf(Relation rel, const TypeName *ofTypename, LOCKMODE lockmode) typeobj.classId = TypeRelationId; typeobj.objectId = typeid; typeobj.objectSubId = 0; + LockNotPinnedObject(TypeRelationId, typeid); recordDependencyOn(&tableobj, &typeobj, DEPENDENCY_NORMAL); /* Update pg_class.reloftype */ @@ -17192,14 +17207,17 @@ AlterRelationNamespaceInternal(Relation classRel, Oid relOid, CatalogTupleUpdate(classRel, &classTup->t_self, classTup); /* Update dependency on schema if caller said so */ - if (hasDependEntry && - changeDependencyFor(RelationRelationId, - relOid, - NamespaceRelationId, - oldNspOid, - newNspOid) != 1) - elog(ERROR, "could not change schema dependency for relation \"%s\"", - NameStr(classForm->relname)); + if (hasDependEntry) + { + LockNotPinnedObject(NamespaceRelationId, newNspOid); + if (changeDependencyFor(RelationRelationId, + relOid, + NamespaceRelationId, + oldNspOid, + newNspOid) != 1) + elog(ERROR, "could not change schema dependency for relation \"%s\"", + NameStr(classForm->relname)); + } } if (!already_done) { diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c index 58b7fc5bbd..4e60a3c06f 100644 --- a/src/backend/commands/trigger.c +++ b/src/backend/commands/trigger.c @@ -1018,8 +1018,6 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, ((Form_pg_class) GETSTRUCT(tuple))->relhastriggers = true; CatalogTupleUpdate(pgrel, &tuple->t_self, tuple); - - CommandCounterIncrement(); } else CacheInvalidateRelcacheByTuple(tuple); @@ -1027,6 +1025,13 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, heap_freetuple(tuple); table_close(pgrel, RowExclusiveLock); + /* + * CommandCounterIncrement() here to ensure the new trigger entry is + * visible when LockNotPinnedObject() will check its existence before + * recording the dependencies. + */ + CommandCounterIncrement(); + /* * If we're replacing a trigger, flush all the old dependencies before * recording new ones. @@ -1045,6 +1050,7 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, referenced.classId = ProcedureRelationId; referenced.objectId = funcoid; referenced.objectSubId = 0; + LockNotPinnedObject(ProcedureRelationId, funcoid); recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); if (isInternal && OidIsValid(constraintOid)) @@ -1058,6 +1064,7 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, referenced.classId = ConstraintRelationId; referenced.objectId = constraintOid; referenced.objectSubId = 0; + LockNotPinnedObject(ConstraintRelationId, constraintOid); recordDependencyOn(&myself, &referenced, DEPENDENCY_INTERNAL); } else @@ -1070,6 +1077,8 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, referenced.classId = RelationRelationId; referenced.objectId = RelationGetRelid(rel); referenced.objectSubId = 0; + + LockNotPinnedObject(RelationRelationId, RelationGetRelid(rel)); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); if (OidIsValid(constrrelid)) @@ -1077,6 +1086,8 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, referenced.classId = RelationRelationId; referenced.objectId = constrrelid; referenced.objectSubId = 0; + + LockNotPinnedObject(RelationRelationId, constrrelid); recordDependencyOn(&myself, &referenced, DEPENDENCY_AUTO); } /* Not possible to have an index dependency in this case */ @@ -1091,6 +1102,7 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, referenced.classId = ConstraintRelationId; referenced.objectId = constraintOid; referenced.objectSubId = 0; + LockNotPinnedObject(TriggerRelationId, trigoid); recordDependencyOn(&referenced, &myself, DEPENDENCY_INTERNAL); } @@ -1100,8 +1112,11 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, if (OidIsValid(parentTriggerOid)) { ObjectAddressSet(referenced, TriggerRelationId, parentTriggerOid); + LockNotPinnedObject(TriggerRelationId, parentTriggerOid); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, RelationGetRelid(rel)); + + LockNotPinnedObject(RelationRelationId, RelationGetRelid(rel)); recordDependencyOn(&myself, &referenced, DEPENDENCY_PARTITION_SEC); } } @@ -1110,12 +1125,19 @@ CreateTriggerFiringOn(CreateTrigStmt *stmt, const char *queryString, if (columns != NULL) { int i; + bool locked_object = false; referenced.classId = RelationRelationId; referenced.objectId = RelationGetRelid(rel); for (i = 0; i < ncolumns; i++) { referenced.objectSubId = columns[i]; + + if (!locked_object) + { + LockNotPinnedObject(RelationRelationId, RelationGetRelid(rel)); + locked_object = true; + } recordDependencyOn(&myself, &referenced, DEPENDENCY_NORMAL); } } @@ -1255,9 +1277,12 @@ TriggerSetParentTrigger(Relation trigRel, ObjectAddressSet(depender, TriggerRelationId, childTrigId); ObjectAddressSet(referenced, TriggerRelationId, parentTrigId); + LockNotPinnedObject(TriggerRelationId, parentTrigId); recordDependencyOn(&depender, &referenced, DEPENDENCY_PARTITION_PRI); ObjectAddressSet(referenced, RelationRelationId, childTableId); + + LockNotPinnedObject(RelationRelationId, childTableId); recordDependencyOn(&depender, &referenced, DEPENDENCY_PARTITION_SEC); } else diff --git a/src/backend/commands/tsearchcmds.c b/src/backend/commands/tsearchcmds.c index b7b5019f1e..cba2b32a4d 100644 --- a/src/backend/commands/tsearchcmds.c +++ b/src/backend/commands/tsearchcmds.c @@ -66,12 +66,12 @@ static DefElem *buildDefItem(const char *name, const char *val, /* --------------------- TS Parser commands ------------------------ */ /* - * lookup a parser support function and return its OID (as a Datum) + * lookup a parser support function and return its OID * * attnum is the pg_ts_parser column the function will go into */ -static Datum -get_ts_parser_func(DefElem *defel, int attnum) +static Oid +get_ts_parser_func_oid(DefElem *defel, int attnum) { List *funcName = defGetQualifiedName(defel); Oid typeId[3]; @@ -125,7 +125,7 @@ get_ts_parser_func(DefElem *defel, int attnum) func_signature_string(funcName, nargs, NIL, typeId), format_type_be(retTypeId)))); - return ObjectIdGetDatum(procOid); + return procOid; } /* @@ -214,6 +214,7 @@ DefineTSParser(List *names, List *parameters) namestrcpy(&pname, prsname); values[Anum_pg_ts_parser_prsname - 1] = NameGetDatum(&pname); values[Anum_pg_ts_parser_prsnamespace - 1] = ObjectIdGetDatum(namespaceoid); + LockNotPinnedObject(NamespaceRelationId, namespaceoid); /* * loop over the definition list and extract the information we need. @@ -224,28 +225,38 @@ DefineTSParser(List *names, List *parameters) if (strcmp(defel->defname, "start") == 0) { - values[Anum_pg_ts_parser_prsstart - 1] = - get_ts_parser_func(defel, Anum_pg_ts_parser_prsstart); + Oid procoid = get_ts_parser_func_oid(defel, Anum_pg_ts_parser_prsstart); + + values[Anum_pg_ts_parser_prsstart - 1] = ObjectIdGetDatum(procoid); + LockNotPinnedObject(ProcedureRelationId, procoid); } else if (strcmp(defel->defname, "gettoken") == 0) { - values[Anum_pg_ts_parser_prstoken - 1] = - get_ts_parser_func(defel, Anum_pg_ts_parser_prstoken); + Oid procoid = get_ts_parser_func_oid(defel, Anum_pg_ts_parser_prstoken); + + values[Anum_pg_ts_parser_prstoken - 1] = ObjectIdGetDatum(procoid); + LockNotPinnedObject(ProcedureRelationId, procoid); } else if (strcmp(defel->defname, "end") == 0) { - values[Anum_pg_ts_parser_prsend - 1] = - get_ts_parser_func(defel, Anum_pg_ts_parser_prsend); + Oid procoid = get_ts_parser_func_oid(defel, Anum_pg_ts_parser_prsend); + + values[Anum_pg_ts_parser_prsend - 1] = ObjectIdGetDatum(procoid); + LockNotPinnedObject(ProcedureRelationId, procoid); } else if (strcmp(defel->defname, "headline") == 0) { - values[Anum_pg_ts_parser_prsheadline - 1] = - get_ts_parser_func(defel, Anum_pg_ts_parser_prsheadline); + Oid procoid = get_ts_parser_func_oid(defel, Anum_pg_ts_parser_prsheadline); + + values[Anum_pg_ts_parser_prsheadline - 1] = ObjectIdGetDatum(procoid); + LockNotPinnedObject(ProcedureRelationId, procoid); } else if (strcmp(defel->defname, "lextypes") == 0) { - values[Anum_pg_ts_parser_prslextype - 1] = - get_ts_parser_func(defel, Anum_pg_ts_parser_prslextype); + Oid procoid = get_ts_parser_func_oid(defel, Anum_pg_ts_parser_prslextype); + + values[Anum_pg_ts_parser_prslextype - 1] = ObjectIdGetDatum(procoid); + LockNotPinnedObject(ProcedureRelationId, procoid); } else ereport(ERROR, @@ -474,6 +485,10 @@ DefineTSDictionary(List *names, List *parameters) CatalogTupleInsert(dictRel, tup); + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, namespaceoid); + LockNotPinnedObject(TSTemplateRelationId, templId); + address = makeDictionaryDependencies(tup); /* Post creation hook for new text search dictionary */ @@ -601,12 +616,12 @@ AlterTSDictionary(AlterTSDictionaryStmt *stmt) /* ---------------------- TS Template commands -----------------------*/ /* - * lookup a template support function and return its OID (as a Datum) + * lookup a template support function and return its OID * * attnum is the pg_ts_template column the function will go into */ -static Datum -get_ts_template_func(DefElem *defel, int attnum) +static Oid +get_ts_template_func_oid(DefElem *defel, int attnum) { List *funcName = defGetQualifiedName(defel); Oid typeId[4]; @@ -642,7 +657,7 @@ get_ts_template_func(DefElem *defel, int attnum) func_signature_string(funcName, nargs, NIL, typeId), format_type_be(retTypeId)))); - return ObjectIdGetDatum(procOid); + return procOid; } /* @@ -723,6 +738,7 @@ DefineTSTemplate(List *names, List *parameters) namestrcpy(&dname, tmplname); values[Anum_pg_ts_template_tmplname - 1] = NameGetDatum(&dname); values[Anum_pg_ts_template_tmplnamespace - 1] = ObjectIdGetDatum(namespaceoid); + LockNotPinnedObject(NamespaceRelationId, namespaceoid); /* * loop over the definition list and extract the information we need. @@ -733,15 +749,19 @@ DefineTSTemplate(List *names, List *parameters) if (strcmp(defel->defname, "init") == 0) { - values[Anum_pg_ts_template_tmplinit - 1] = - get_ts_template_func(defel, Anum_pg_ts_template_tmplinit); + Oid procoid = get_ts_template_func_oid(defel, Anum_pg_ts_template_tmplinit); + + values[Anum_pg_ts_template_tmplinit - 1] = ObjectIdGetDatum(procoid); nulls[Anum_pg_ts_template_tmplinit - 1] = false; + LockNotPinnedObject(ProcedureRelationId, procoid); } else if (strcmp(defel->defname, "lexize") == 0) { - values[Anum_pg_ts_template_tmpllexize - 1] = - get_ts_template_func(defel, Anum_pg_ts_template_tmpllexize); + Oid procoid = get_ts_template_func_oid(defel, Anum_pg_ts_template_tmpllexize); + + values[Anum_pg_ts_template_tmpllexize - 1] = ObjectIdGetDatum(procoid); nulls[Anum_pg_ts_template_tmpllexize - 1] = false; + LockNotPinnedObject(ProcedureRelationId, procoid); } else ereport(ERROR, @@ -879,6 +899,7 @@ makeConfigurationDependencies(HeapTuple tuple, bool removeOld, referenced.objectId = cfgmap->mapdict; referenced.objectSubId = 0; add_exact_object_address(&referenced, addrs); + LockNotPinnedObject(TSDictionaryRelationId, cfgmap->mapdict); } systable_endscan(scan); @@ -998,6 +1019,10 @@ DefineTSConfiguration(List *names, List *parameters, ObjectAddress *copied) values[Anum_pg_ts_config_cfgowner - 1] = ObjectIdGetDatum(GetUserId()); values[Anum_pg_ts_config_cfgparser - 1] = ObjectIdGetDatum(prsOid); + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, namespaceoid); + LockNotPinnedObject(TSParserRelationId, prsOid); + tup = heap_form_tuple(cfgRel->rd_att, values, nulls); CatalogTupleInsert(cfgRel, tup); @@ -1156,6 +1181,7 @@ ObjectAddress AlterTSConfiguration(AlterTSConfigurationStmt *stmt) { HeapTuple tup; + Form_pg_ts_config cfg; Oid cfgId; Relation relMap; ObjectAddress address; @@ -1168,7 +1194,8 @@ AlterTSConfiguration(AlterTSConfigurationStmt *stmt) errmsg("text search configuration \"%s\" does not exist", NameListToString(stmt->cfgname)))); - cfgId = ((Form_pg_ts_config) GETSTRUCT(tup))->oid; + cfg = (Form_pg_ts_config) GETSTRUCT(tup); + cfgId = cfg->oid; /* must be owner */ if (!object_ownercheck(TSConfigRelationId, cfgId, GetUserId())) @@ -1183,6 +1210,10 @@ AlterTSConfiguration(AlterTSConfigurationStmt *stmt) else if (stmt->tokentype) DropConfigurationMapping(stmt, tup, relMap); + /* Lock dependent objects */ + LockNotPinnedObject(NamespaceRelationId, cfg->cfgnamespace); + LockNotPinnedObject(TSParserRelationId, cfg->cfgparser); + /* Update dependencies */ makeConfigurationDependencies(tup, true, relMap); @@ -1414,6 +1445,8 @@ MakeConfigurationMapping(AlterTSConfigurationStmt *stmt, repl_val[Anum_pg_ts_config_map_mapdict - 1] = ObjectIdGetDatum(dictNew); repl_repl[Anum_pg_ts_config_map_mapdict - 1] = true; + LockNotPinnedObject(TSDictionaryRelationId, dictNew); + newtup = heap_modify_tuple(maptup, RelationGetDescr(relMap), repl_val, repl_null, repl_repl); @@ -1456,6 +1489,8 @@ MakeConfigurationMapping(AlterTSConfigurationStmt *stmt, slot[slotCount]->tts_values[Anum_pg_ts_config_map_mapseqno - 1] = Int32GetDatum(j + 1); slot[slotCount]->tts_values[Anum_pg_ts_config_map_mapdict - 1] = ObjectIdGetDatum(dictIds[j]); + LockNotPinnedObject(TSDictionaryRelationId, dictIds[j]); + ExecStoreVirtualTuple(slot[slotCount]); slotCount++; diff --git a/src/backend/commands/typecmds.c b/src/backend/commands/typecmds.c index 2a1e713335..9febaa24a7 100644 --- a/src/backend/commands/typecmds.c +++ b/src/backend/commands/typecmds.c @@ -1794,6 +1794,7 @@ makeRangeConstructors(const char *name, Oid namespace, * that they go away silently when the type is dropped. Note that * pg_dump depends on this choice to avoid dumping the constructors. */ + LockNotPinnedObject(TypeRelationId, rangeOid); recordDependencyOn(&myself, &referenced, DEPENDENCY_INTERNAL); } } @@ -1859,6 +1860,7 @@ makeMultirangeConstructors(const char *name, Oid namespace, * that they go away silently when the type is dropped. Note that pg_dump * depends on this choice to avoid dumping the constructors. */ + LockNotPinnedObject(TypeRelationId, multirangeOid); recordDependencyOn(&myself, &referenced, DEPENDENCY_INTERNAL); pfree(argtypes); @@ -2672,6 +2674,45 @@ AlterDomainDefault(List *names, Node *defaultRaw) CatalogTupleUpdate(rel, &tup->t_self, newtuple); + /* Lock dependent objects */ + typTup = (Form_pg_type) GETSTRUCT(newtuple); + + if (OidIsValid(typTup->typnamespace)) + LockNotPinnedObject(NamespaceRelationId, typTup->typnamespace); + + if (OidIsValid(typTup->typinput)) + LockNotPinnedObject(ProcedureRelationId, typTup->typinput); + + if (OidIsValid(typTup->typoutput)) + LockNotPinnedObject(ProcedureRelationId, typTup->typoutput); + + if (OidIsValid(typTup->typreceive)) + LockNotPinnedObject(ProcedureRelationId, typTup->typreceive); + + if (OidIsValid(typTup->typsend)) + LockNotPinnedObject(ProcedureRelationId, typTup->typsend); + + if (OidIsValid(typTup->typmodin)) + LockNotPinnedObject(ProcedureRelationId, typTup->typmodin); + + if (OidIsValid(typTup->typmodout)) + LockNotPinnedObject(ProcedureRelationId, typTup->typmodout); + + if (OidIsValid(typTup->typanalyze)) + LockNotPinnedObject(ProcedureRelationId, typTup->typanalyze); + + if (OidIsValid(typTup->typsubscript)) + LockNotPinnedObject(ProcedureRelationId, typTup->typsubscript); + + if (OidIsValid(typTup->typbasetype)) + LockNotPinnedObject(TypeRelationId, typTup->typbasetype); + + if (OidIsValid(typTup->typcollation)) + LockNotPinnedObject(CollationRelationId, typTup->typcollation); + + if (OidIsValid(typTup->typelem)) + LockNotPinnedObject(TypeRelationId, typTup->typelem); + /* Rebuild dependencies */ GenerateTypeDependencies(newtuple, rel, @@ -4276,10 +4317,13 @@ AlterTypeNamespaceInternal(Oid typeOid, Oid nspOid, if (oldNspOid != nspOid && (isCompositeType || typform->typtype != TYPTYPE_COMPOSITE) && !isImplicitArray) + { + LockNotPinnedObject(NamespaceRelationId, nspOid); if (changeDependencyFor(TypeRelationId, typeOid, NamespaceRelationId, oldNspOid, nspOid) != 1) elog(ERROR, "could not change schema dependency for type \"%s\"", format_type_be(typeOid)); + } InvokeObjectPostAlterHook(TypeRelationId, typeOid, 0); @@ -4571,6 +4615,7 @@ AlterTypeRecurse(Oid typeOid, bool isImplicitArray, SysScanDesc scan; ScanKeyData key[1]; HeapTuple domainTup; + Form_pg_type typeForm; /* Since this function recurses, it could be driven to stack overflow */ check_stack_depth(); @@ -4619,6 +4664,45 @@ AlterTypeRecurse(Oid typeOid, bool isImplicitArray, newtup = heap_modify_tuple(tup, RelationGetDescr(catalog), values, nulls, replaces); + /* Lock dependent objects */ + typeForm = (Form_pg_type) GETSTRUCT(newtup); + + if (OidIsValid(typeForm->typnamespace)) + LockNotPinnedObject(NamespaceRelationId, typeForm->typnamespace); + + if (OidIsValid(typeForm->typinput)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typinput); + + if (OidIsValid(typeForm->typoutput)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typoutput); + + if (OidIsValid(typeForm->typreceive)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typreceive); + + if (OidIsValid(typeForm->typsend)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typsend); + + if (OidIsValid(typeForm->typmodin)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typmodin); + + if (OidIsValid(typeForm->typmodout)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typmodout); + + if (OidIsValid(typeForm->typanalyze)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typanalyze); + + if (OidIsValid(typeForm->typsubscript)) + LockNotPinnedObject(ProcedureRelationId, typeForm->typsubscript); + + if (OidIsValid(typeForm->typbasetype)) + LockNotPinnedObject(TypeRelationId, typeForm->typbasetype); + + if (OidIsValid(typeForm->typcollation)) + LockNotPinnedObject(CollationRelationId, typeForm->typcollation); + + if (OidIsValid(typeForm->typelem)) + LockNotPinnedObject(TypeRelationId, typeForm->typelem); + CatalogTupleUpdate(catalog, &newtup->t_self, newtup); /* Rebuild dependencies for this type */ diff --git a/src/backend/rewrite/rewriteDefine.c b/src/backend/rewrite/rewriteDefine.c index 6cc9a8d8bf..c930eca262 100644 --- a/src/backend/rewrite/rewriteDefine.c +++ b/src/backend/rewrite/rewriteDefine.c @@ -155,6 +155,7 @@ InsertRule(const char *rulname, referenced.objectId = eventrel_oid; referenced.objectSubId = 0; + LockNotPinnedObject(RelationRelationId, eventrel_oid); recordDependencyOn(&myself, &referenced, (evtype == CMD_SELECT) ? DEPENDENCY_INTERNAL : DEPENDENCY_AUTO); diff --git a/src/backend/utils/errcodes.txt b/src/backend/utils/errcodes.txt index 3250d539e1..60e8539fe3 100644 --- a/src/backend/utils/errcodes.txt +++ b/src/backend/utils/errcodes.txt @@ -271,6 +271,7 @@ Section: Class 28 - Invalid Authorization Specification Section: Class 2B - Dependent Privilege Descriptors Still Exist 2B000 E ERRCODE_DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST dependent_privilege_descriptors_still_exist +2BP02 E ERRCODE_DEPENDENT_OBJECTS_DOES_NOT_EXIST dependent_objects_does_not_exist 2BP01 E ERRCODE_DEPENDENT_OBJECTS_STILL_EXIST dependent_objects_still_exist Section: Class 2D - Invalid Transaction Termination diff --git a/src/include/catalog/dependency.h b/src/include/catalog/dependency.h index 6908ca7180..93da8b353e 100644 --- a/src/include/catalog/dependency.h +++ b/src/include/catalog/dependency.h @@ -101,6 +101,9 @@ typedef struct ObjectAddresses ObjectAddresses; /* in dependency.c */ extern void AcquireDeletionLock(const ObjectAddress *object, int flags); +extern void LockNotPinnedObjectById(const ObjectAddress *object); +extern void LockNotPinnedObjectsById(const ObjectAddress *object, int nobject); +extern void LockNotPinnedObject(Oid classid, Oid objid); extern void ReleaseDeletionLock(const ObjectAddress *object); @@ -128,6 +131,9 @@ extern void add_exact_object_address(const ObjectAddress *object, extern bool object_address_present(const ObjectAddress *object, const ObjectAddresses *addrs); +extern void lock_record_object_address_dependencies(const ObjectAddress *depender, + ObjectAddresses *referenced, + DependencyType behavior); extern void record_object_address_dependencies(const ObjectAddress *depender, ObjectAddresses *referenced, DependencyType behavior); @@ -172,6 +178,7 @@ extern long changeDependenciesOf(Oid classId, Oid oldObjectId, extern long changeDependenciesOn(Oid refClassId, Oid oldRefObjectId, Oid newRefObjectId); +extern bool isObjectPinned(const ObjectAddress *object); extern Oid getExtensionOfObject(Oid classId, Oid objectId); extern List *getAutoExtensionsOfObject(Oid classId, Oid objectId); diff --git a/src/include/catalog/objectaddress.h b/src/include/catalog/objectaddress.h index 3a70d80e32..56f746264b 100644 --- a/src/include/catalog/objectaddress.h +++ b/src/include/catalog/objectaddress.h @@ -53,6 +53,7 @@ extern void check_object_ownership(Oid roleid, Node *object, Relation relation); extern Oid get_object_namespace(const ObjectAddress *address); +extern bool ObjectByIdExist(const ObjectAddress *address); extern bool is_objectclass_supported(Oid class_id); extern const char *get_object_class_descr(Oid class_id); diff --git a/src/include/storage/lock.h b/src/include/storage/lock.h index 0017d4b868..f7cbb224fc 100644 --- a/src/include/storage/lock.h +++ b/src/include/storage/lock.h @@ -568,6 +568,15 @@ extern void LockReleaseSession(LOCKMETHODID lockmethodid); extern void LockReleaseCurrentOwner(LOCALLOCK **locallocks, int nlocks); extern void LockReassignCurrentOwner(LOCALLOCK **locallocks, int nlocks); extern bool LockHeldByMe(const LOCKTAG *locktag, LOCKMODE lockmode); + +#define ObjectIsLocked(tag) \ + LockHeldByMe(tag,ShareLock) || LockHeldByMe(tag,AccessExclusiveLock) || \ + LockHeldByMe(tag,RowExclusiveLock) || LockHeldByMe(tag,RowShareLock) || \ + LockHeldByMe(tag,AccessShareLock) || \ + LockHeldByMe(tag,ShareRowExclusiveLock) || \ + LockHeldByMe(tag,ExclusiveLock) || \ + LockHeldByMe(tag,ShareUpdateExclusiveLock) + #ifdef USE_ASSERT_CHECKING extern HTAB *GetLockMethodLocalHash(void); #endif diff --git a/src/test/isolation/expected/test_dependencies_locks.out b/src/test/isolation/expected/test_dependencies_locks.out new file mode 100644 index 0000000000..9b645d7aa5 --- /dev/null +++ b/src/test/isolation/expected/test_dependencies_locks.out @@ -0,0 +1,129 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_create_function_in_schema s2_drop_schema s1_commit +step s1_begin: BEGIN; +step s1_create_function_in_schema: CREATE FUNCTION testschema.foo() RETURNS int AS 'select 1' LANGUAGE sql; +step s2_drop_schema: DROP SCHEMA testschema; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_schema: <... completed> +ERROR: cannot drop schema testschema because other objects depend on it + +starting permutation: s2_begin s2_drop_schema s1_create_function_in_schema s2_commit +step s2_begin: BEGIN; +step s2_drop_schema: DROP SCHEMA testschema; +step s1_create_function_in_schema: CREATE FUNCTION testschema.foo() RETURNS int AS 'select 1' LANGUAGE sql; <waiting ...> +step s2_commit: COMMIT; +step s1_create_function_in_schema: <... completed> +ERROR: schema testschema does not exist + +starting permutation: s1_begin s1_alter_function_schema s2_drop_alterschema s1_commit +step s1_begin: BEGIN; +step s1_alter_function_schema: ALTER FUNCTION public.falter() SET SCHEMA alterschema; +step s2_drop_alterschema: DROP SCHEMA alterschema; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_alterschema: <... completed> +ERROR: cannot drop schema alterschema because other objects depend on it + +starting permutation: s2_begin s2_drop_alterschema s1_alter_function_schema s2_commit +step s2_begin: BEGIN; +step s2_drop_alterschema: DROP SCHEMA alterschema; +step s1_alter_function_schema: ALTER FUNCTION public.falter() SET SCHEMA alterschema; <waiting ...> +step s2_commit: COMMIT; +step s1_alter_function_schema: <... completed> +ERROR: schema alterschema does not exist + +starting permutation: s1_begin s1_create_function_with_argtype s2_drop_foo_type s1_commit +step s1_begin: BEGIN; +step s1_create_function_with_argtype: CREATE FUNCTION fooargtype(num foo) RETURNS int AS 'select 1' LANGUAGE sql; +step s2_drop_foo_type: DROP TYPE public.foo; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_foo_type: <... completed> +ERROR: cannot drop type foo because other objects depend on it + +starting permutation: s2_begin s2_drop_foo_type s1_create_function_with_argtype s2_commit +step s2_begin: BEGIN; +step s2_drop_foo_type: DROP TYPE public.foo; +step s1_create_function_with_argtype: CREATE FUNCTION fooargtype(num foo) RETURNS int AS 'select 1' LANGUAGE sql; <waiting ...> +step s2_commit: COMMIT; +step s1_create_function_with_argtype: <... completed> +ERROR: type foo does not exist + +starting permutation: s1_begin s1_create_function_with_rettype s2_drop_foo_rettype s1_commit +step s1_begin: BEGIN; +step s1_create_function_with_rettype: CREATE FUNCTION footrettype() RETURNS id LANGUAGE sql RETURN 1; +step s2_drop_foo_rettype: DROP DOMAIN id; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_foo_rettype: <... completed> +ERROR: cannot drop type id because other objects depend on it + +starting permutation: s2_begin s2_drop_foo_rettype s1_create_function_with_rettype s2_commit +step s2_begin: BEGIN; +step s2_drop_foo_rettype: DROP DOMAIN id; +step s1_create_function_with_rettype: CREATE FUNCTION footrettype() RETURNS id LANGUAGE sql RETURN 1; <waiting ...> +step s2_commit: COMMIT; +step s1_create_function_with_rettype: <... completed> +ERROR: type id does not exist + +starting permutation: s1_begin s1_create_function_with_function s2_drop_function_f s1_commit +step s1_begin: BEGIN; +step s1_create_function_with_function: CREATE FUNCTION foofunc() RETURNS int LANGUAGE SQL RETURN f() + 1; +step s2_drop_function_f: DROP FUNCTION f(); <waiting ...> +step s1_commit: COMMIT; +step s2_drop_function_f: <... completed> +ERROR: cannot drop function f() because other objects depend on it + +starting permutation: s2_begin s2_drop_function_f s1_create_function_with_function s2_commit +step s2_begin: BEGIN; +step s2_drop_function_f: DROP FUNCTION f(); +step s1_create_function_with_function: CREATE FUNCTION foofunc() RETURNS int LANGUAGE SQL RETURN f() + 1; <waiting ...> +step s2_commit: COMMIT; +step s1_create_function_with_function: <... completed> +ERROR: function f() does not exist + +starting permutation: s1_begin s1_create_domain_with_domain s2_drop_domain_id s1_commit +step s1_begin: BEGIN; +step s1_create_domain_with_domain: CREATE DOMAIN idid as id; +step s2_drop_domain_id: DROP DOMAIN id; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_domain_id: <... completed> +ERROR: cannot drop type id because other objects depend on it + +starting permutation: s2_begin s2_drop_domain_id s1_create_domain_with_domain s2_commit +step s2_begin: BEGIN; +step s2_drop_domain_id: DROP DOMAIN id; +step s1_create_domain_with_domain: CREATE DOMAIN idid as id; <waiting ...> +step s2_commit: COMMIT; +step s1_create_domain_with_domain: <... completed> +ERROR: type id does not exist + +starting permutation: s1_begin s1_create_table_with_type s2_drop_footab_type s1_commit +step s1_begin: BEGIN; +step s1_create_table_with_type: CREATE TABLE tabtype(a footab); +step s2_drop_footab_type: DROP TYPE public.footab; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_footab_type: <... completed> +ERROR: cannot drop type footab because other objects depend on it + +starting permutation: s2_begin s2_drop_footab_type s1_create_table_with_type s2_commit +step s2_begin: BEGIN; +step s2_drop_footab_type: DROP TYPE public.footab; +step s1_create_table_with_type: CREATE TABLE tabtype(a footab); <waiting ...> +step s2_commit: COMMIT; +step s1_create_table_with_type: <... completed> +ERROR: type footab does not exist + +starting permutation: s1_begin s1_create_server_with_fdw_wrapper s2_drop_fdw_wrapper s1_commit +step s1_begin: BEGIN; +step s1_create_server_with_fdw_wrapper: CREATE SERVER srv_fdw_wrapper FOREIGN DATA WRAPPER fdw_wrapper; +step s2_drop_fdw_wrapper: DROP FOREIGN DATA WRAPPER fdw_wrapper RESTRICT; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_fdw_wrapper: <... completed> +ERROR: cannot drop foreign-data wrapper fdw_wrapper because other objects depend on it + +starting permutation: s2_begin s2_drop_fdw_wrapper s1_create_server_with_fdw_wrapper s2_commit +step s2_begin: BEGIN; +step s2_drop_fdw_wrapper: DROP FOREIGN DATA WRAPPER fdw_wrapper RESTRICT; +step s1_create_server_with_fdw_wrapper: CREATE SERVER srv_fdw_wrapper FOREIGN DATA WRAPPER fdw_wrapper; <waiting ...> +step s2_commit: COMMIT; +step s1_create_server_with_fdw_wrapper: <... completed> +ERROR: foreign-data wrapper fdw_wrapper does not exist diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index 0342eb39e4..1b67f0bffe 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -114,3 +114,4 @@ test: serializable-parallel-2 test: serializable-parallel-3 test: matview-write-skew test: lock-nowait +test: test_dependencies_locks diff --git a/src/test/isolation/specs/test_dependencies_locks.spec b/src/test/isolation/specs/test_dependencies_locks.spec new file mode 100644 index 0000000000..5d04dfe9dc --- /dev/null +++ b/src/test/isolation/specs/test_dependencies_locks.spec @@ -0,0 +1,89 @@ +setup +{ + CREATE SCHEMA testschema; + CREATE SCHEMA alterschema; + CREATE TYPE public.foo as enum ('one', 'two'); + CREATE TYPE public.footab as enum ('three', 'four'); + CREATE DOMAIN id AS int; + CREATE FUNCTION f() RETURNS int LANGUAGE SQL RETURN 1; + CREATE FUNCTION public.falter() RETURNS int LANGUAGE SQL RETURN 1; + CREATE FOREIGN DATA WRAPPER fdw_wrapper; +} + +teardown +{ + DROP FUNCTION IF EXISTS testschema.foo(); + DROP FUNCTION IF EXISTS fooargtype(num foo); + DROP FUNCTION IF EXISTS footrettype(); + DROP FUNCTION IF EXISTS foofunc(); + DROP FUNCTION IF EXISTS public.falter(); + DROP FUNCTION IF EXISTS alterschema.falter(); + DROP DOMAIN IF EXISTS idid; + DROP SERVER IF EXISTS srv_fdw_wrapper; + DROP TABLE IF EXISTS tabtype; + DROP SCHEMA IF EXISTS testschema; + DROP SCHEMA IF EXISTS alterschema; + DROP TYPE IF EXISTS public.foo; + DROP TYPE IF EXISTS public.footab; + DROP DOMAIN IF EXISTS id; + DROP FUNCTION IF EXISTS f(); + DROP FOREIGN DATA WRAPPER IF EXISTS fdw_wrapper; +} + +session "s1" + +step "s1_begin" { BEGIN; } +step "s1_create_function_in_schema" { CREATE FUNCTION testschema.foo() RETURNS int AS 'select 1' LANGUAGE sql; } +step "s1_create_function_with_argtype" { CREATE FUNCTION fooargtype(num foo) RETURNS int AS 'select 1' LANGUAGE sql; } +step "s1_create_function_with_rettype" { CREATE FUNCTION footrettype() RETURNS id LANGUAGE sql RETURN 1; } +step "s1_create_function_with_function" { CREATE FUNCTION foofunc() RETURNS int LANGUAGE SQL RETURN f() + 1; } +step "s1_alter_function_schema" { ALTER FUNCTION public.falter() SET SCHEMA alterschema; } +step "s1_create_domain_with_domain" { CREATE DOMAIN idid as id; } +step "s1_create_table_with_type" { CREATE TABLE tabtype(a footab); } +step "s1_create_server_with_fdw_wrapper" { CREATE SERVER srv_fdw_wrapper FOREIGN DATA WRAPPER fdw_wrapper; } +step "s1_commit" { COMMIT; } + +session "s2" + +step "s2_begin" { BEGIN; } +step "s2_drop_schema" { DROP SCHEMA testschema; } +step "s2_drop_alterschema" { DROP SCHEMA alterschema; } +step "s2_drop_foo_type" { DROP TYPE public.foo; } +step "s2_drop_foo_rettype" { DROP DOMAIN id; } +step "s2_drop_footab_type" { DROP TYPE public.footab; } +step "s2_drop_function_f" { DROP FUNCTION f(); } +step "s2_drop_domain_id" { DROP DOMAIN id; } +step "s2_drop_fdw_wrapper" { DROP FOREIGN DATA WRAPPER fdw_wrapper RESTRICT; } +step "s2_commit" { COMMIT; } + +# function - schema +permutation "s1_begin" "s1_create_function_in_schema" "s2_drop_schema" "s1_commit" +permutation "s2_begin" "s2_drop_schema" "s1_create_function_in_schema" "s2_commit" + +# alter function - schema +permutation "s1_begin" "s1_alter_function_schema" "s2_drop_alterschema" "s1_commit" +permutation "s2_begin" "s2_drop_alterschema" "s1_alter_function_schema" "s2_commit" + +# function - argtype +permutation "s1_begin" "s1_create_function_with_argtype" "s2_drop_foo_type" "s1_commit" +permutation "s2_begin" "s2_drop_foo_type" "s1_create_function_with_argtype" "s2_commit" + +# function - rettype +permutation "s1_begin" "s1_create_function_with_rettype" "s2_drop_foo_rettype" "s1_commit" +permutation "s2_begin" "s2_drop_foo_rettype" "s1_create_function_with_rettype" "s2_commit" + +# function - function +permutation "s1_begin" "s1_create_function_with_function" "s2_drop_function_f" "s1_commit" +permutation "s2_begin" "s2_drop_function_f" "s1_create_function_with_function" "s2_commit" + +# domain - domain +permutation "s1_begin" "s1_create_domain_with_domain" "s2_drop_domain_id" "s1_commit" +permutation "s2_begin" "s2_drop_domain_id" "s1_create_domain_with_domain" "s2_commit" + +# table - type +permutation "s1_begin" "s1_create_table_with_type" "s2_drop_footab_type" "s1_commit" +permutation "s2_begin" "s2_drop_footab_type" "s1_create_table_with_type" "s2_commit" + +# server - foreign data wrapper +permutation "s1_begin" "s1_create_server_with_fdw_wrapper" "s2_drop_fdw_wrapper" "s1_commit" +permutation "s2_begin" "s2_drop_fdw_wrapper" "s1_create_server_with_fdw_wrapper" "s2_commit" diff --git a/src/test/modules/test_oat_hooks/expected/alter_table.out b/src/test/modules/test_oat_hooks/expected/alter_table.out index 8cbacca2c9..df8d276dfc 100644 --- a/src/test/modules/test_oat_hooks/expected/alter_table.out +++ b/src/test/modules/test_oat_hooks/expected/alter_table.out @@ -37,6 +37,8 @@ NOTICE: in object access: superuser attempting create (subId=0x0) [internal] NOTICE: in object access: superuser finished create (subId=0x0) [internal] NOTICE: in object access: superuser attempting create (subId=0x0) [internal] NOTICE: in object access: superuser finished create (subId=0x0) [internal] +NOTICE: in object access: superuser attempting namespace search (subId=0x0) [no report on violation, allowed] +NOTICE: in object access: superuser finished namespace search (subId=0x0) [no report on violation, allowed] NOTICE: in process utility: superuser finished CREATE TABLE CREATE RULE test_oat_notify AS ON UPDATE TO test_oat_schema.test_oat_tab @@ -62,8 +64,6 @@ BEGIN END IF; END; $$; NOTICE: in process utility: superuser attempting CREATE FUNCTION -NOTICE: in object access: superuser attempting namespace search (subId=0x0) [no report on violation, allowed] -NOTICE: in object access: superuser finished namespace search (subId=0x0) [no report on violation, allowed] NOTICE: in object access: superuser attempting create (subId=0x0) [explicit] NOTICE: in object access: superuser finished create (subId=0x0) [explicit] NOTICE: in process utility: superuser finished CREATE FUNCTION diff --git a/src/test/modules/test_oat_hooks/expected/test_oat_hooks.out b/src/test/modules/test_oat_hooks/expected/test_oat_hooks.out index effdc49145..da6d931994 100644 --- a/src/test/modules/test_oat_hooks/expected/test_oat_hooks.out +++ b/src/test/modules/test_oat_hooks/expected/test_oat_hooks.out @@ -86,6 +86,8 @@ NOTICE: in object access: superuser attempting create (subId=0x0) [internal] NOTICE: in object access: superuser finished create (subId=0x0) [internal] NOTICE: in object access: superuser attempting create (subId=0x0) [internal] NOTICE: in object access: superuser finished create (subId=0x0) [internal] +NOTICE: in object access: superuser attempting namespace search (subId=0x0) [no report on violation, allowed] +NOTICE: in object access: superuser finished namespace search (subId=0x0) [no report on violation, allowed] NOTICE: in process utility: superuser finished CREATE TABLE CREATE INDEX regress_test_table_t_idx ON regress_test_table (t); NOTICE: in process utility: superuser attempting CREATE INDEX diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out index 673361e840..c2115ea601 100644 --- a/src/test/regress/expected/alter_table.out +++ b/src/test/regress/expected/alter_table.out @@ -2867,11 +2867,12 @@ begin; alter table alterlock2 add constraint alterlock2nv foreign key (f1) references alterlock (f1) NOT VALID; select * from my_locks order by 1; - relname | max_lockmode -------------+----------------------- - alterlock | ShareRowExclusiveLock - alterlock2 | ShareRowExclusiveLock -(2 rows) + relname | max_lockmode +----------------+----------------------- + alterlock | ShareRowExclusiveLock + alterlock2 | ShareRowExclusiveLock + alterlock_pkey | AccessShareLock +(3 rows) commit; begin; -- 2.34.1 --4uRv/LTCVmpgUZqs-- ^ permalink raw reply [nested|flat] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ 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; 186+ 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] 186+ messages in thread
end of thread, other threads:[~2026-07-03 05:52 UTC | newest] Thread overview: 186+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2024-03-29 15:43 [PATCH v12] Avoid orphaned objects dependencies 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-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-03-29 19:45 [PATCH v2 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-04-24 08:44 [PATCH v3 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-05-14 21:51 [PATCH v4 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-06-30 13:21 [PATCH v5] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-03 05:52 [PATCH v6] Support changing a column into a stored generated column Alberto Piai <[email protected]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox