fix: detect and apply GENERATED ALWAYS AS changes on existing columns (#591) - #592
Conversation
…#591) columnsEqual never looked at IsGenerated, GeneratedKind, or GeneratedExpr, so changing the expression of a generated column, or switching a column between plain and generated, produced an empty plan and left every existing database computing the old value. The generation clause is now part of the column comparison, and the change is applied with the narrowest DDL PostgreSQL offers: - expression change: ALTER COLUMN ... SET EXPRESSION AS (PostgreSQL 17+) - STORED -> plain: ALTER COLUMN ... DROP EXPRESSION - plain -> generated, STORED <-> VIRTUAL, VIRTUAL -> plain, and expression changes on PostgreSQL 14-16: DROP COLUMN + ADD COLUMN. A generated column holds no data of its own, so nothing is lost; indexes and constraints that DROP COLUMN takes with it are re-created from the desired state, and foreign keys bound to a replaced unique/PK constraint go through the existing pre-drop/post-add path. The target major version is threaded into the diff through the new GenerateMigrationForTarget so the plan command can pick the right form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Greptile SummaryThis PR detects generated-column definition changes, gates PostgreSQL-version-specific alteration syntax, and recreates columns and selected dependents when no in-place DDL exists. The recreation path is incomplete for existing data and several dependent schema properties:
Confidence Score: 0/5This PR is not safe to merge because generated-column recreation can lose existing values and privileges, fail on unchanged dependent views, and leave indexes missing or stale. Five independent migration failures remain in the new recreation path: destructive VIRTUAL-to-plain conversion, omitted column grants, unhandled unchanged views, incorrect same-named index handling, and false-negative dependency matching for quoted identifiers. Files Needing Attention: internal/diff/column.go, internal/diff/table.go, internal/diff/diff.go Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Generated clause changed] --> B{Supported in-place ALTER?}
B -->|Yes| C[SET or DROP EXPRESSION]
B -->|No| D[DROP COLUMN]
D --> E[ADD COLUMN]
E --> F[Restore selected constraints and indexes]
D -. currently uncovered .-> G[Preserve existing virtual values]
D -. currently uncovered .-> H[Pre-drop and recreate unchanged views]
E -. currently uncovered .-> I[Restore unchanged column grants]
F -. matcher/name gaps .-> J[Missing or stale indexes]
Reviews (1): Last reviewed commit: "fix: detect and apply GENERATED ALWAYS A..." | Re-trigger Greptile |
There was a problem hiding this comment.
🟡 Changes recommended
Five critical dependency, index, identifier, and privilege-handling issues must be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds detection and migration support for changes to existing generated columns, including PostgreSQL version-specific DDL and dependency recreation.
Changes:
- Compares generated-column kind and expression during schema diffing.
- Uses
SET/DROP EXPRESSIONwhere supported; otherwise recreates columns and dependencies. - Adds STORED/VIRTUAL fixtures and version-gating tests.
- Introduces target-version-aware migration generation.
Five critical issues remain in internal/diff/table.go:
- Recreated columns do not account for unchanged dependent views and transitive dependents.
- Index recreation checks the new index instead of the old index, potentially preserving stale definitions.
- Foreign keys backed by standalone unique indexes are not included in dependency recreation.
- Quoted identifiers containing embedded quotes are not matched correctly.
- Column-level privileges are lost when columns are dropped and re-added.
File summaries
| File | Description |
|---|---|
testdata/diff/create_table/issue_591_alter_generated_virtual/plan.txt |
Expected textual VIRTUAL-column plan. |
testdata/diff/create_table/issue_591_alter_generated_virtual/plan.sql |
Expected SQL VIRTUAL-column plan. |
testdata/diff/create_table/issue_591_alter_generated_virtual/plan.json |
Expected JSON VIRTUAL-column plan. |
testdata/diff/create_table/issue_591_alter_generated_virtual/old.sql |
Original VIRTUAL-column schema. |
testdata/diff/create_table/issue_591_alter_generated_virtual/new.sql |
Desired VIRTUAL-column schema. |
testdata/diff/create_table/issue_591_alter_generated_virtual/diff.sql |
Expected VIRTUAL-column migration DDL. |
testdata/diff/create_table/issue_591_alter_generated_column/plan.txt |
Expected textual STORED-column plan. |
testdata/diff/create_table/issue_591_alter_generated_column/plan.sql |
Expected SQL STORED-column plan. |
testdata/diff/create_table/issue_591_alter_generated_column/plan.json |
Expected JSON STORED-column plan. |
testdata/diff/create_table/issue_591_alter_generated_column/old.sql |
Original STORED-column schema. |
testdata/diff/create_table/issue_591_alter_generated_column/new.sql |
Desired STORED-column schema. |
testdata/diff/create_table/issue_591_alter_generated_column/diff.sql |
Expected STORED-column migration DDL. |
internal/diff/table.go |
Recreates generated columns and dependent schema objects. |
internal/diff/generated_column_test.go |
Tests version gating and expression matching. |
internal/diff/diff.go |
Adds target-version-aware migration generation. |
internal/diff/column.go |
Detects and emits generated-column changes. |
cmd/plan/plan.go |
Passes the target PostgreSQL version into diff generation. |
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 5
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e its expression (#591) Review follow-ups for the generated-column change: - Strip same-schema qualifiers from GeneratedExpr in the IR normalizer, as is already done for defaults and index expressions. pg_get_expr qualifies a same-schema function depending on the inspecting session's search_path, so the current state read public.calc_priority() while the desired state read calc_priority(), and the second plan re-emitted SET EXPRESSION AS (dependency/table_fk_to_generated_column idempotency failure in CI). - Views that read a re-created column are put through the existing pre-drop/recreate cycle even when unchanged, including their transitive dependents; otherwise DROP COLUMN fails with SQLSTATE 2BP01. - Column grants touching a re-created column are left out of the old state so the desired grant is re-issued after the column exists again. - Foreign keys bound to a standalone unique index on a re-created column go through the #439 pre-drop/post-add path like FKs bound to a constraint. - The implicit-drop decision for a same-named index inspects the old definition, so an index moving onto the re-created column takes the normal drop + add path instead of leaving the stale index in place. - Quoted identifiers with embedded quotes are matched in their "a""b" form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 7ab3aa6 addressing the review and the CI failure. CI ( Review findings, all fixed except one:
New case |
There was a problem hiding this comment.
🟡 Changes recommended
Two critical dependency-handling defects and three moderate object-recreation defects remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
internal/diff/diff.go:1015
- When
dependsOnRecreatedis the only change, thisviewDiffcontains no trigger additions. The pre-drop removes every INSTEAD OF trigger on the view, and the root-view recreation path only recreates entries inAddedTriggers, so unchanged desired triggers silently disappear. Populate the recreation diff with all desired triggers (including disabled state/comments), as is already done for transitively recreated views.
diff.modifiedViews = append(diff.modifiedViews, &viewDiff{
Old: oldView,
New: newView,
RequiresRecreate: true,
})
- Files reviewed: 25/25 changed files
- Comments generated: 4
- Review effort level: Balanced
…ated column (#591) Second round of review follow-ups for the column recreation path: - Dependency detection for views uses the live (old) definition, so a view whose new definition no longer reads the column is still pre-dropped. - Policies and triggers whose expressions (USING / WITH CHECK, WHEN, UPDATE OF) name a re-created column block DROP COLUMN with SQLSTATE 2BP01. They are now dropped ahead of the column (policies just before the table changes, triggers in the drop phase) and created again from the desired state. - Grants on views this migration drops and creates again (root recreations and their transitive dependents) are left out of the old state so they are re-issued after the views exist again. - $ counts as an identifier character in the expression matcher. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 1a7365b for the second review round.
On the suppressed comment about INSTEAD OF triggers: the recreate path already creates every desired trigger from
|
There was a problem hiding this comment.
🟡 Changes recommended
Multiple critical dependency-handling defects can produce failed or incorrect migrations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 25/25 changed files
- Comments generated: 6
- Review effort level: Balanced
…laced generated column (#591) Third round of review follow-ups: - Indexes that the textual dependency check attributes to a re-created column are dropped explicitly as well as re-created. The DROP uses IF EXISTS, so it is a no-op when DROP COLUMN already took the index and still removes a stale index when the check was a false positive. A name directly preceded by ":" is now treated as a type cast, not a column. - Column grants: the old grant stays in the old state so removals on the surviving columns of a grouped grant are still revoked; a desired grant touching a re-created column is re-issued after the column is back even when it matches the old one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 68ef6b7 for the third review round. Fixed
Not changed, with reasons on the threads
Apply and idempotency pass on PG18 for the issue cases and the dependency case, along with the full diff suite. |
There was a problem hiding this comment.
🟡 Changes recommended
Quoted table names containing embedded quotes can evade view dependency detection and make column recreation fail.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 25/25 changed files
- Comments generated: 1
- Review effort level: Balanced
…ks (#591) containsIdentifier (view -> relation dependencies) and exprReferencesAnyColumn (expression -> column dependencies) each had their own regex with different gaps: the former missed quoted identifiers with embedded quotes and matched inside string literals, the latter carried the quoting and boundary rules alone. Both now build on identifierRegexp in identifier_match.go, which renders the bare and the quoted spelling (quotes doubled) of every name segment, applies identifier boundaries including $, strips string literals, excludes function calls and type casts in column mode, and caches compiled patterns. viewDependsOnTable matches schema and table segments separately through containsQualifiedIdentifier. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 02eb0eb for the fourth review round. The quoted-table-name miss was in
Future textual dependency checks should build on |
There was a problem hiding this comment.
🟡 Changes recommended
Generated-column rewrites still risk failed or destructive migrations, stale stored values, and missed dependencies for valid quoted identifiers.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
internal/diff/column.go:74
- Generated-column rewrites run during
generateModifyTablesSQL, beforegenerateModifyFunctionsSQL. If this expression calls an immutable function whose body is also changed in the same plan,SET EXPRESSION(and the pre-17 drop/add path) computes stored values using the old function; the function is replaced afterward, and a second plan reports no drift although existing rows are stale. Schedule the relevant function modification before the column rewrite, or force another recomputation afterward.
ir/normalize.go:198 StripSchemaPrefixFromBodyonly recognizes the literal unquoted prefixschema., butpg_get_exprquotes mixed-case, reserved, or otherwise non-simple schema names (for example,"My Schema".calc(a)). The current-state expression therefore keeps that qualifier while the temporary-schema expression is stripped, producing a perpetual generated-column diff and repeated rewrites for supported quoted schema names. Normalize the quotedquote_ident(schema).form as well, with proper embedded-quote handling.
- Files reviewed: 28/28 changed files
- Comments generated: 3
- Review effort level: Balanced
…pendency checks (#591) Fifth round of review follow-ups: - StripSchemaPrefixFromBody also strips the quote_ident form of the schema ("My Schema".calc(a) -> calc(a)), which the deparsers render for schema names that need quoting. Without it the current state kept the qualifier while the desired state lost it, so a generated column in such a schema was rewritten on every plan. Index expressions and function bodies gain the same normalization. - containsIdentifier tries a dotted name as one identifier (a quoted "a.b") before falling back to schema.name matching, restoring the pre-refactor behaviour for such column names; dotted names never match inside a longer qualified path. - viewDependsOnRecreatedColumn iterates the table diffs instead of parsing a flattened schema.table key, so a schema name containing a dot works. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in eb21036 for the fifth review round. Fixed
Not changed
Full diff suite, dump suite, ir tests, and apply/idempotency for the issue cases pass locally. |
There was a problem hiding this comment.
🟡 Changes recommended
Generated-expression normalization and dependency ordering can emit incorrect or failing migrations and omit dependent constraints.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 29/29 changed files
- Comments generated: 4
- Review effort level: Balanced
…dentifiers around a replaced generated column (#591) Sixth round of review follow-ups: - An EXCLUDE constraint that references a re-created column only through an expression element (conkey records 0) is detected from its definition text, dropped explicitly ahead of the column, and added back afterwards. - A foreign key newly added to an existing table that targets a unique index rebuilt with a re-created column is deferred to the post-add step instead of being emitted (possibly inline) before the index exists again. - StripSchemaPrefixFromBody copies double-quoted identifiers verbatim unless the token is the quoted schema itself, so a column literally named "public.foo" is no longer rewritten to "foo". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 8d4ed70 for the sixth review round. Fixed
Not changed
Full diff suite, dump suite, ir tests, and apply/idempotency for the issue cases pass locally. |
There was a problem hiding this comment.
🟡 Changes recommended
Newly added unique-index FK dependencies and column privileges on recreated views can still produce failed migrations or lost grants.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/diff/diff.go:1514
- Recreated views are excluded from the old object-privilege set above, but their column privileges still compare equal here. Since the inspector includes views/materialized views in
ColumnPrivilegesandDROP VIEWremoves their column ACLs, an unchangedGRANT SELECT (col) ON viewis silently lost. Mark privileges on every relation inrecreatedViewKeysfor re-grant as well.
- Files reviewed: 29/29 changed files
- Comments generated: 2
- Review effort level: Balanced
…ileges on recreated views (#591) Seventh round of review follow-ups: - Foreign keys that target a standalone unique index created by this migration are deferred to the post-add step whether the index is rebuilt with a re-created column or entirely new: an FK newly added to an existing table, or an existing FK whose new definition targets such an index, would otherwise be emitted before the index exists (always for a self-reference). - Column grants on views the migration drops and creates again are re-issued after the views exist again, like the object-level grants already were. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in 601595b for the seventh review round. All three points fixed:
Full diff suite, plan tests, and apply/idempotency for the issue cases pass locally; no existing goldens changed from widening the FK deferral. |
There was a problem hiding this comment.
🟡 Changes recommended
Identifier matching can mistake keywords or case-distinct quoted identifiers for recreated columns, triggering unrelated drop-and-recreate operations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 29/29 changed files
- Comments generated: 1
- Review effort level: Balanced
…ve form (#591) Deparsers render a name that needs quoting (mixed case, special characters, reserved word) only double-quoted, and quoted identifiers are case-sensitive. identifierSpellings now emits just an exact quoted branch for such names and keeps the case-insensitive bare branch for the rest, so a column named "select" no longer matches every SELECT keyword and foo no longer matches the distinct column "Foo". Such false positives only caused redundant drop and re-create operations, but on views and indexes those are not free. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up in eca9dec for the eighth review round: the shared identifier matcher only emits a bare branch for names that have a valid unquoted spelling and matches the quoted branch exactly, so reserved-word or case-distinct quoted columns no longer trigger unrelated recreations. Full diff suite and apply/idempotency for the issue cases pass locally. |
There was a problem hiding this comment.
🔵 Needs a closer look
Destructive column recreation and cross-object dependency handling warrant final human validation despite extensive regression coverage.
Review details
- Files reviewed: 29/29 changed files
- Comments generated: 1
- Review effort level: Balanced
…#591) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The STORED, VIRTUAL, and dependent-object scenarios now live in create_table/issue_591_alter_generated_column, grouped by scenario with comments; one embedded-postgres cycle instead of three. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Folded the three |
Summary
columnsEqualcompared name, type, nullability, default, max length, identity and comment, but never the generation clause. Changing the expression of an existingGENERATED ALWAYS AS (...)column, or switching a column between plain and generated, produced an empty plan, so every existing database silently kept the old expression.The generation clause (
IsGenerated,GeneratedKind,GeneratedExpr) is now part of the comparison, and the change is applied with the narrowest DDL PostgreSQL offers:ALTER COLUMN ... SET EXPRESSION AS (...)ALTER COLUMN ... DROP EXPRESSION(a followingSET DEFAULTetc. applies as usual)DROP COLUMN+ADD COLUMNPostgreSQL has no
ALTERform for the last row (DROP EXPRESSIONis rejected for VIRTUAL columns, verified on 18). Since a generated column holds no data of its own, re-creating it loses nothing. The indexes and constraints thatDROP COLUMNtakes with it are re-created from the desired state, and foreign keys bound to a replaced unique/PK constraint go through the existing #439 pre-drop/post-add path.To pick the right form, the plan command now passes the target's major version into the diff via the new
diff.GenerateMigrationForTarget;GenerateMigrationandGenerateMigrationWithOptionsare unchanged (version 0 = assume a current server).Fixes #591
Test plan
testdata/diff/create_table/issue_591_alter_generated_column(PG18, one apply cycle) covers, grouped by scenario: a STORED expression change with a dependent index; STORED -> plain with a new default; plain -> STORED with a check constraint, unique constraint bound by an FK, and an expression/partial index; VIRTUAL expression change, VIRTUAL -> plain, STORED -> VIRTUAL; and a re-created column with dependent views (including one whose old definition reads it), a policy, a trigger, column and view grants, an expression EXCLUDE constraint, a standalone unique index bound by FKs (existing and newly added), and a same-named index moving onto the column. The core scenarios produced an empty plan before the fix.internal/diff/generated_column_test.go: version gate (SET EXPRESSION on 0/17/18, DROP + ADD + index re-creation on 14/16) and the expression/column reference matcher.🤖 Generated with Claude Code