diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go index 057ded03..0c66970e 100644 --- a/cmd/plan/plan.go +++ b/cmd/plan/plan.go @@ -395,17 +395,19 @@ func GeneratePlan(config *PlanConfig, provider postgres.DesiredStateProvider) (* normalizeSchemaNames(desiredStateIR, schemaToInspect, config.Schema) } - // Generate diff (current -> desired) using IR directly - diffs := diff.GenerateMigration(currentStateIR, desiredStateIR, config.Schema) - // Extract the target database's major version (e.g. "PostgreSQL 18.1" -> 18) - // to gate version-specific rewrites. Zero (unknown) falls back to the - // version-portable rewrite patterns. + // to gate version-specific DDL and rewrites. Zero (unknown) is handled + // differently by the two consumers: the diff assumes a current server and + // may emit DDL that older servers reject (e.g. SET EXPRESSION AS, PG17+), + // while the plan rewrites fall back to their version-portable patterns. targetMajorVersion := 0 if v, ok := strings.CutPrefix(currentStateIR.Metadata.DatabaseVersion, "PostgreSQL "); ok { fmt.Sscanf(v, "%d", &targetMajorVersion) } + // Generate diff (current -> desired) using IR directly + diffs := diff.GenerateMigrationForTarget(currentStateIR, desiredStateIR, config.Schema, targetMajorVersion) + // Create plan from diffs with fingerprint migrationPlan := plan.NewPlanWithFingerprint(diffs, sourceFingerprint, targetMajorVersion, currentStateIR) diff --git a/internal/diff/column.go b/internal/diff/column.go index 3b822952..75603420 100644 --- a/internal/diff/column.go +++ b/internal/diff/column.go @@ -31,6 +31,17 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch hasOldDefault := oldDefault != nil && *oldDefault != "" needsUsing := hasTypeChange && needsUsingClause(oldBaseType, newBaseType) + // A STORED generated column turning into a plain column keeps its current + // values and simply stops being recomputed (issue #591). Emit it first so + // the remaining clauses (type, default, NOT NULL) act on a plain column. + // VIRTUAL -> plain and the other transitions without an ALTER form are + // handled by re-creating the column instead (see generatedColumnNeedsRecreate). + if cd.Old.IsGenerated && !cd.New.IsGenerated { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP EXPRESSION;", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name)) + statements = append(statements, sql) + } + // If type is changing with USING clause and there's an existing default, drop the default first if needsUsing && hasOldDefault { sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP DEFAULT;", @@ -54,6 +65,15 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch } } + // Handle generation expression changes (issue #591). SET EXPRESSION AS + // (PostgreSQL 17+) recomputes STORED values in place; on older servers the + // column is re-created instead and never reaches this point. + if cd.Old.IsGenerated && cd.New.IsGenerated && generatedExpr(cd.Old) != generatedExpr(cd.New) { + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET EXPRESSION AS (%s);", + qualifiedTableName, ir.QuoteIdentifier(cd.New.Name), generatedExpr(cd.New)) + statements = append(statements, sql) + } + // Handle nullable changes if cd.Old.IsNullable != cd.New.IsNullable { if cd.New.IsNullable { @@ -217,5 +237,63 @@ func columnsEqual(old, new *ir.Column, targetSchema string) bool { return false } + // Compare the generation clause (issue #591) + if generatedColumnChanged(old, new) { + return false + } + return true } + +// generatedExpr returns the generation expression of a column, or "" when the +// column is not generated. +func generatedExpr(c *ir.Column) string { + if !c.IsGenerated || c.GeneratedExpr == nil { + return "" + } + return *c.GeneratedExpr +} + +// generatedColumnChanged reports whether the generation clause differs between +// two versions of a column: plain vs generated, STORED vs VIRTUAL, or the +// expression itself. Both expressions come from pg_get_expr on the same +// inspector, so they compare textually (issue #591). +func generatedColumnChanged(old, new *ir.Column) bool { + if old.IsGenerated != new.IsGenerated { + return true + } + if !new.IsGenerated { + return false + } + return old.GeneratedKind != new.GeneratedKind || generatedExpr(old) != generatedExpr(new) +} + +// generatedColumnNeedsRecreate reports whether a generation-clause change can +// only be applied by dropping and re-adding the column. PostgreSQL has no ALTER +// form for turning a plain column into a generated one, for switching between +// STORED and VIRTUAL, or for dropping the expression of a VIRTUAL column, and +// ALTER COLUMN ... SET EXPRESSION AS only exists on PostgreSQL 17+. A generated +// column holds no data of its own, so recreating it loses nothing; the indexes +// and constraints that DROP COLUMN takes with it are re-created by diffTables. +// targetMajorVersion 0 means unknown and is treated as a current server. +func generatedColumnNeedsRecreate(old, new *ir.Column, targetMajorVersion int) bool { + if !generatedColumnChanged(old, new) { + return false + } + switch { + case !old.IsGenerated: + // plain -> generated + return true + case !new.IsGenerated: + // generated -> plain: DROP EXPRESSION only works on STORED columns. + // A VIRTUAL column has no stored values to keep (which is why + // PostgreSQL refuses DROP EXPRESSION for it), so the re-created + // plain column starts out NULL; the plan shows it as dropped + added. + return old.GeneratedKind == "v" + case old.GeneratedKind != new.GeneratedKind: + return true + default: + // expression change: SET EXPRESSION AS needs PostgreSQL 17+ + return targetMajorVersion != 0 && targetMajorVersion < 17 + } +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 5997df58..fc55d161 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -270,6 +270,7 @@ type Diff struct { } type ddlDiff struct { + targetMajorVersion int // target PostgreSQL major version, 0 if unknown (gates version-specific DDL) addedSchemas []*ir.Schema droppedSchemas []*ir.Schema modifiedSchemas []*schemaDiff @@ -433,14 +434,20 @@ type tableDiff struct { AddedTriggers []*ir.Trigger DroppedTriggers []*ir.Trigger ModifiedTriggers []*triggerDiff - AddedPolicies []*ir.RLSPolicy - DroppedPolicies []*ir.RLSPolicy - ModifiedPolicies []*policyDiff - RLSChanges []*rlsChange - CommentChanged bool - OldComment string - NewComment string - PersistenceChanged bool + // RecreatedColumns names columns applied as DROP COLUMN + ADD COLUMN + // because their generation clause cannot be altered in place (issue #591). + // They appear in both DroppedColumns and AddedColumns; dependent objects + // outside the table (views, column grants, FKs bound to a unique index) + // are re-created from this set. + RecreatedColumns map[string]bool + AddedPolicies []*ir.RLSPolicy + DroppedPolicies []*ir.RLSPolicy + ModifiedPolicies []*policyDiff + RLSChanges []*rlsChange + CommentChanged bool + OldComment string + NewComment string + PersistenceChanged bool } // ColumnDiff represents changes to a column @@ -478,7 +485,16 @@ type rlsChange struct { // GenerateMigration generates the migration diff using standard "smart qualification" // (the target-schema prefix is omitted on entity names). func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { - return GenerateMigrationWithOptions(oldIR, newIR, targetSchema, false) + return generateMigration(oldIR, newIR, targetSchema, false, 0) +} + +// GenerateMigrationForTarget is GenerateMigration for a target database whose +// PostgreSQL major version is known (0 if unknown, treated as a current +// server). The version gates DDL that only newer servers accept, such as +// ALTER COLUMN ... SET EXPRESSION AS (PostgreSQL 17+); older targets get a +// version-portable equivalent instead (issue #591). +func GenerateMigrationForTarget(oldIR, newIR *ir.IR, targetSchema string, targetMajorVersion int) []Diff { + return generateMigration(oldIR, newIR, targetSchema, false, targetMajorVersion) } // GenerateMigrationWithOptions is like GenerateMigration, but when qualifySchema is @@ -491,7 +507,12 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { // stay bare because the IR stores them without schema identity (#493). Default // behavior (false) is unchanged for plan/apply. func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qualifySchema bool) []Diff { + return generateMigration(oldIR, newIR, targetSchema, qualifySchema, 0) +} + +func generateMigration(oldIR, newIR *ir.IR, targetSchema string, qualifySchema bool, targetMajorVersion int) []Diff { diff := &ddlDiff{ + targetMajorVersion: targetMajorVersion, addedSchemas: []*ir.Schema{}, droppedSchemas: []*ir.Schema{}, modifiedSchemas: []*schemaDiff{}, @@ -635,7 +656,7 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual diff.modifiedTables = append(diff.modifiedTables, tableDiff) } } else { - if tableDiff := diffTables(oldTable, newTable, targetSchema); tableDiff != nil { + if tableDiff := diffTables(oldTable, newTable, targetSchema, diff.targetMajorVersion); tableDiff != nil { diff.modifiedTables = append(diff.modifiedTables, tableDiff) } } @@ -644,6 +665,9 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual diff.allNewTables = newTables + // Columns re-created by this migration, keyed by schema.table (issue #591) + recreatedColumnsByTable := collectRecreatedColumns(diff.modifiedTables) + // Compare rows of data-managed tables diff.dataDiffs = diffTableData(oldTables, newTables) @@ -920,6 +944,11 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual newView := newViews[key] if oldView, exists := oldViews[key]; exists { structurallyDifferent := !viewsEqual(oldView, newView) + // A view that reads a column being re-created (DROP COLUMN + ADD + // COLUMN) would block the DROP with SQLSTATE 2BP01, so it goes + // through the pre-drop/recreate cycle even when unchanged (#591). + // The live (old) definition is what holds the dependency. + dependsOnRecreated := viewDependsOnRecreatedColumn(oldView, diff.modifiedTables) // Check if the view definition itself changed (excluding options). // This is used to decide if materialized views need DROP+CREATE: // option-only changes should use ALTER VIEW SET/RESET, not recreation. @@ -970,13 +999,14 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual addedTriggers, droppedTriggers, modifiedTriggers := diffViewTriggers(oldView, newView) triggersChanged := len(addedTriggers) > 0 || len(droppedTriggers) > 0 || len(modifiedTriggers) > 0 - if structurallyDifferent || commentChanged || indexesChanged || triggersChanged { + if structurallyDifferent || commentChanged || indexesChanged || triggersChanged || dependsOnRecreated { // For materialized views with definition changes, mark for recreation. // For regular views with column changes incompatible with CREATE OR REPLACE VIEW, // also mark for recreation (issue #308). // Use definitionChanged (not structurallyDifferent) so that option-only changes // on materialized views use ALTER SET/RESET instead of DROP+CREATE. - needsRecreate := definitionChanged && (newView.Materialized || viewColumnsRequireRecreate(oldView, newView)) + needsRecreate := dependsOnRecreated || + (definitionChanged && (newView.Materialized || viewColumnsRequireRecreate(oldView, newView))) if needsRecreate { diff.modifiedViews = append(diff.modifiedViews, &viewDiff{ @@ -1053,6 +1083,11 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual // Store all new views for dependent view handling (issue #268) diff.allNewViews = newViews + // Views this migration drops and creates again (root recreations and + // their transitive dependents), keyed by schema.name. DROP VIEW discards + // the view's ACL, so their grants are re-issued below (#591). + recreatedViewKeys := collectRecreatedViewKeys(diff.allNewViews, diff.modifiedViews, diff.addedViews) + // Compare sequences across all schemas oldSequences := make(map[string]*ir.Sequence) newSequences := make(map[string]*ir.Sequence) @@ -1255,6 +1290,14 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual for _, dbSchema := range oldIR.Schemas { for _, p := range dbSchema.Privileges { + // A grant on a view that is dropped and created again by this + // migration does not survive; leaving it out of the old state + // makes the desired grant come back as an addition, emitted after + // the views are recreated (#591). + if (p.ObjectType == ir.PrivilegeObjectTypeTable || p.ObjectType == ir.PrivilegeObjectTypeView) && + recreatedViewKeys[dbSchema.Name+"."+p.ObjectName] { + continue + } key := p.GetFullKey() oldPrivs[key] = p } @@ -1455,10 +1498,22 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual } } + // Desired grants that touch a column this migration re-creates, or sit on + // a view it drops and creates again, keyed like newColPrivs. DROP COLUMN + // discards that column's ACL and DROP VIEW the whole relation's, so such + // a grant must be issued again afterwards even when the old and desired + // grants match (#591). The old grant stays in the old state so that + // removals on surviving columns of a grouped grant are still revoked by + // the normal comparison. + newColPrivsOnRecreated := make(map[string]bool) for _, dbSchema := range newIR.Schemas { for _, cp := range dbSchema.ColumnPrivileges { key := cp.GetFullKey() newColPrivs[key] = cp + relationKey := dbSchema.Name + "." + cp.TableName + if recreatedViewKeys[relationKey] || columnPrivilegeTouchesColumns(cp, recreatedColumnsByTable[relationKey]) { + newColPrivsOnRecreated[key] = true + } } } @@ -1515,9 +1570,10 @@ func GenerateMigrationWithOptions(oldIR, newIR *ir.IR, targetSchema string, qual } } - // Find added column privileges + // Find added column privileges. A matched grant on a re-created column is + // added as well: it is re-issued after the column is back (#591). for fullKey, cp := range newColPrivs { - if !matchedNewColPrivs[fullKey] { + if !matchedNewColPrivs[fullKey] || newColPrivsOnRecreated[fullKey] { diff.addedColumnPrivileges = append(diff.addedColumnPrivileges, cp) } } @@ -2193,7 +2249,7 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto generateDropRecreatedFKsSQL(d.fkPreDrops, targetSchema, collector) // Modify tables - generateModifyTablesSQL(d.modifiedTables, d.droppedTables, d.fkPreDrops, targetSchema, collector) + generateModifyTablesSQL(d.modifiedTables, d.droppedTables, d.fkPreDrops, d.suppressedInlineFKs, targetSchema, collector) // Attach OWNED BY for explicitly created sequences whose owning column was // created by this migration, either with a new table (create phase) or by @@ -2349,6 +2405,71 @@ func filterPreDroppedViews(views []*ir.View, preDropped map[string]bool) []*ir.V return filtered } +// collectRecreatedColumns indexes the columns re-created by this migration +// (see tableDiff.RecreatedColumns) by their schema.table key. (#591) +func collectRecreatedColumns(modifiedTables []*tableDiff) map[string]map[string]bool { + byTable := make(map[string]map[string]bool) + for _, td := range modifiedTables { + if len(td.RecreatedColumns) > 0 { + byTable[td.Table.Schema+"."+td.Table.Name] = td.RecreatedColumns + } + } + return byTable +} + +// viewDependsOnRecreatedColumn reports whether a view reads a column that this +// migration re-creates, in which case the view must be dropped before the +// column and created again afterwards. The column check is a textual match on +// the view definition, so a same-named column of another table the view also +// reads can cause a redundant recreation. (#591) +func viewDependsOnRecreatedColumn(view *ir.View, modifiedTables []*tableDiff) bool { + for _, td := range modifiedTables { + if len(td.RecreatedColumns) == 0 { + continue + } + if viewDependsOnTable(view, td.Table.Schema, td.Table.Name) && exprReferencesAnyColumn(view.Definition, td.RecreatedColumns) { + return true + } + } + return false +} + +// collectRecreatedViewKeys returns the schema.name keys of every view the +// migration drops and creates again: views marked RequiresRecreate and the +// views that transitively depend on them (see generateModifyViewsSQL). +func collectRecreatedViewKeys(allNewViews map[string]*ir.View, modifiedViews []*viewDiff, addedViews []*ir.View) map[string]bool { + keys := make(map[string]bool) + for _, vd := range modifiedViews { + if vd.RequiresRecreate { + keys[vd.New.Schema+"."+vd.New.Name] = true + } + } + if len(keys) == 0 { + return keys + } + ctx := findDependentViewsForRecreatedViews(allNewViews, modifiedViews, addedViews) + for _, dependents := range ctx.dependents { + for _, view := range dependents { + keys[view.Schema+"."+view.Name] = true + } + } + return keys +} + +// columnPrivilegeTouchesColumns reports whether a column grant covers any of +// the given columns. +func columnPrivilegeTouchesColumns(cp *ir.ColumnPrivilege, columns map[string]bool) bool { + if len(columns) == 0 { + return false + } + for _, name := range cp.Columns { + if columns[name] { + return true + } + } + return false +} + // getTableNameWithSchema returns the table name with schema qualification only when necessary // If the table schema is different from the target schema, it returns "schema.table" // If they are the same, it returns just "table" diff --git a/internal/diff/generated_column_test.go b/internal/diff/generated_column_test.go new file mode 100644 index 00000000..a19a3ddf --- /dev/null +++ b/internal/diff/generated_column_test.go @@ -0,0 +1,78 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/pgplex/pgschema/ir" +) + +// generatedColumnIR builds a one-table IR whose column "doubled" is +// GENERATED ALWAYS AS (expr) STORED, with a plain index on it. +func generatedColumnIR(expr string) *ir.IR { + table := &ir.Table{ + Schema: "public", + Name: "metrics", + Type: ir.TableTypeBase, + Columns: []*ir.Column{ + {Name: "a", Position: 1, DataType: "integer", IsNullable: false}, + {Name: "doubled", Position: 2, DataType: "integer", IsNullable: true, + IsGenerated: true, GeneratedKind: "s", GeneratedExpr: &expr}, + }, + Constraints: map[string]*ir.Constraint{}, + Indexes: map[string]*ir.Index{ + "metrics_doubled_idx": { + Schema: "public", Table: "metrics", Name: "metrics_doubled_idx", + Type: ir.IndexTypeRegular, Method: "btree", + Columns: []*ir.IndexColumn{{Name: "doubled", Position: 1, Direction: "ASC"}}, + }, + }, + } + return &ir.IR{ + Schemas: map[string]*ir.Schema{ + "public": {Name: "public", Tables: map[string]*ir.Table{"metrics": table}}, + }, + } +} + +func migrationSQL(diffs []Diff) string { + var b strings.Builder + for _, d := range diffs { + for _, s := range d.Statements { + b.WriteString(s.SQL) + b.WriteString("\n") + } + } + return b.String() +} + +// A generated-column expression change uses SET EXPRESSION AS on PostgreSQL +// 17+ (and when the version is unknown), and re-creates the column together +// with its dependent index on older servers, where that clause does not +// exist (issue #591). +func TestGeneratedExpressionChange_VersionGate(t *testing.T) { + oldIR := generatedColumnIR("(a * 3)") + newIR := generatedColumnIR("(a * 2)") + + for _, version := range []int{0, 17, 18} { + got := migrationSQL(GenerateMigrationForTarget(oldIR, newIR, "public", version)) + want := "ALTER TABLE metrics ALTER COLUMN doubled SET EXPRESSION AS ((a * 2));\n" + if got != want { + t.Errorf("version %d: got\n%s\nwant\n%s", version, got, want) + } + } + + for _, version := range []int{14, 16} { + got := migrationSQL(GenerateMigrationForTarget(oldIR, newIR, "public", version)) + want := strings.Join([]string{ + "ALTER TABLE metrics DROP COLUMN doubled;", + "ALTER TABLE metrics ADD COLUMN doubled integer GENERATED ALWAYS AS ((a * 2)) STORED;", + "DROP INDEX IF EXISTS metrics_doubled_idx;", + "CREATE INDEX IF NOT EXISTS metrics_doubled_idx ON metrics (doubled);", + "", + }, "\n") + if got != want { + t.Errorf("version %d: got\n%s\nwant\n%s", version, got, want) + } + } +} diff --git a/internal/diff/identifier_match.go b/internal/diff/identifier_match.go new file mode 100644 index 00000000..8d19b9b5 --- /dev/null +++ b/internal/diff/identifier_match.go @@ -0,0 +1,144 @@ +package diff + +import ( + "regexp" + "strings" + "sync" + + "github.com/pgplex/pgschema/ir" +) + +// Textual dependency checks (does this view read that table, does this +// expression name that column) run over SQL text produced by PostgreSQL's +// deparsers: pg_get_viewdef, pg_get_expr, pg_get_indexdef, pg_get_triggerdef. +// Those render an identifier either bare (all-lowercase names) or +// double-quoted with embedded quotes doubled ("a""b"). Every such check goes +// through identifierRegexp so the two spellings, identifier boundaries, string +// literals, function calls, and type casts are handled in one place (#591). + +// identifierMatchMode selects the boundary rules for a match. +type identifierMatchMode int + +const ( + // relationMatch matches a relation (table or view) name; a following "(" + // or a preceding "::" still counts, e.g. "INTO t(...)" or "::t" rowtype casts. + relationMatch identifierMatchMode = iota + // columnMatch matches a column reference inside an expression; a name + // followed by "(" is a function call and one preceded by ":" is a type + // cast, neither of which is a column. + columnMatch +) + +// sqlStringLiteralRegex matches a single-quoted SQL string literal, including +// doubled-quote escapes. +var sqlStringLiteralRegex = regexp.MustCompile(`'(?:[^']|'')*'`) + +// identifierRegexpCache memoizes compiled patterns; view and expression +// dependency checks run the same names over many definitions. +var identifierRegexpCache sync.Map // map[string]*regexp.Regexp + +// identifierSpellings renders the ways a deparser can spell name. A name that +// needs quoting (mixed case, special characters, reserved word) is only ever +// rendered double-quoted, with embedded quotes doubled, and quoted identifiers +// are case-sensitive, so it gets an exact quoted branch alone: a column named +// "select" must not match the SELECT keyword, nor foo the column "Foo". Any +// other name may appear bare (matched case-insensitively) or quoted. +func identifierSpellings(name string) string { + quoted := regexp.QuoteMeta(`"` + strings.ReplaceAll(name, `"`, `""`) + `"`) + if ir.NeedsQuoting(name) { + return quoted + } + return `(?:(?i:` + regexp.QuoteMeta(name) + `)|` + quoted + `)` +} + +// identifierRegexp returns a pattern matching name as a whole identifier in +// deparsed SQL. parts holds the name's qualification segments (schema, name) +// or a single unqualified name; each segment may appear bare or quoted. +func identifierRegexp(mode identifierMatchMode, parts ...string) *regexp.Regexp { + key := string(rune('0'+mode)) + "\x00" + strings.Join(parts, "\x00") + if re, ok := identifierRegexpCache.Load(key); ok { + return re.(*regexp.Regexp) + } + + spellings := make([]string, len(parts)) + for i, part := range parts { + spellings[i] = identifierSpellings(part) + } + body := strings.Join(spellings, `\.`) + + // Identifier characters never border a whole-identifier match, and a + // quote cannot either, so a bare spelling never matches inside a quoted + // identifier. A qualified name (or a name that itself contains a dot) + // must not sit inside a longer path such as other.schema.name. + dotted := len(parts) > 1 + for _, part := range parts { + dotted = dotted || strings.Contains(part, ".") + } + before, after := `[^\w$"]`, `[^\w$"]` + if dotted { + before, after = `[^\w$".]`, `[^\w$".]` + } + if mode == columnMatch { + before = before[:len(before)-1] + `:]` + after = after[:len(after)-1] + `(]` + } + re := regexp.MustCompile(`(?:^|` + before + `)` + body + `(?:` + after + `|$)`) + identifierRegexpCache.Store(key, re) + return re +} + +// stripStringLiterals blanks out single-quoted literals so their contents +// cannot look like identifiers. +func stripStringLiterals(sqlText string) string { + if !strings.Contains(sqlText, "'") { + return sqlText + } + return sqlStringLiteralRegex.ReplaceAllString(sqlText, "''") +} + +// containsIdentifier reports whether sqlText mentions identifier as a whole +// relation name, in bare or quoted form; "foo" does not match "foobar". An +// identifier containing a dot is tried both as one name (a quoted "a.b") and +// as "schema.name" matched segment by segment, so "my schema"."a""b" is found +// for `my schema.a"b` and "other.foo.bar" does not match "foo.bar". Callers +// that hold schema and name separately should use containsQualifiedIdentifier. +func containsIdentifier(sqlText, identifier string) bool { + if sqlText == "" || identifier == "" { + return false + } + sqlText = stripStringLiterals(sqlText) + if identifierRegexp(relationMatch, identifier).MatchString(sqlText) { + return true + } + if schema, name, ok := strings.Cut(identifier, "."); ok { + return identifierRegexp(relationMatch, schema, name).MatchString(sqlText) + } + return false +} + +// containsQualifiedIdentifier reports whether sqlText mentions schema.name as +// a whole qualified relation name, each segment bare or quoted. +func containsQualifiedIdentifier(sqlText, schema, name string) bool { + if sqlText == "" || schema == "" || name == "" { + return false + } + return identifierRegexp(relationMatch, schema, name).MatchString(stripStringLiterals(sqlText)) +} + +// exprReferencesAnyColumn reports whether a deparsed SQL expression names any +// of the columns as a bare or quoted identifier. Callers pair a positive +// result with IF EXISTS drops and a re-create from the desired state, so a +// false positive costs a redundant drop + create while a miss would leave a +// dependent object behind. (#591) +func exprReferencesAnyColumn(expr string, columns map[string]bool) bool { + if expr == "" || len(columns) == 0 { + return false + } + expr = stripStringLiterals(expr) + for column := range columns { + if identifierRegexp(columnMatch, column).MatchString(expr) { + return true + } + } + return false +} diff --git a/internal/diff/identifier_match_test.go b/internal/diff/identifier_match_test.go new file mode 100644 index 00000000..637e901b --- /dev/null +++ b/internal/diff/identifier_match_test.go @@ -0,0 +1,87 @@ +package diff + +import ( + "testing" + + "github.com/pgplex/pgschema/ir" +) + +func TestContainsIdentifier(t *testing.T) { + cases := []struct { + text, ident string + want bool + }{ + {"SELECT id FROM users", "users", true}, + {"SELECT id FROM public.users", "users", true}, + {"SELECT id FROM public.users", "public.users", true}, + {"SELECT id FROM other.public.users", "public.users", false}, + {`SELECT id FROM "Users"`, "Users", true}, + {`SELECT id FROM "a""b"`, `a"b`, true}, + {`SELECT id FROM "a"b"`, `a"b`, false}, + {`SELECT id FROM "my schema"."a""b"`, `my schema.a"b`, true}, + {"SELECT id FROM foobar", "foo", false}, + {`SELECT "a.b" FROM t`, "a.b", true}, // dotted name is one identifier when quoted + {"SELECT a.b FROM t", "a.b", true}, + {"SELECT id FROM foo_bar", "foo", false}, + {"SELECT id FROM foo$bar", "foo", false}, + {"SELECT 'users' FROM t", "users", false}, // string literal + {`SELECT "select" FROM t`, "select", true}, // reserved word is only ever quoted + {"SELECT a FROM t", "select", false}, // ... so the keyword is not a match + {`SELECT "Foo" FROM t`, "foo", false}, // quoted identifiers are case-sensitive + {`SELECT "Foo" FROM t`, "Foo", true}, + {"SELECT foo FROM t", "Foo", false}, + {"SELECT FOO FROM t", "foo", true}, // bare spelling stays case-insensitive + {"SELECT (row)::users FROM t", "users", true}, + {"", "users", false}, + } + for _, c := range cases { + if got := containsIdentifier(c.text, c.ident); got != c.want { + t.Errorf("containsIdentifier(%q, %q) = %v, want %v", c.text, c.ident, got, c.want) + } + } +} + +func TestViewDependsOnTable_QuotedNames(t *testing.T) { + view := &ir.View{Schema: "public", Name: "v", Definition: ` SELECT c FROM "a""b";`} + if !viewDependsOnTable(view, "public", `a"b`) { + t.Errorf("expected view to depend on table a\"b") + } + qualified := &ir.View{Schema: "public", Name: "v", Definition: ` SELECT c FROM "my schema"."Orders";`} + if !viewDependsOnTable(qualified, "my schema", "Orders") { + t.Errorf("expected view to depend on \"my schema\".\"Orders\"") + } + if viewDependsOnTable(qualified, "my schema", "Order") { + t.Errorf("did not expect a prefix match") + } +} + +func TestExprReferencesAnyColumn(t *testing.T) { + cols := map[string]bool{"b": true, "my col": true, `a"b`: true} + cases := []struct { + expr string + want bool + }{ + {"(b + 1)", true}, + {"b", true}, + {"(t.b > 0)", true}, + {"(\"my col\" * 2)", true}, + {"(b > 10)", true}, + {"(bb + 1)", false}, + {"(a + 1)", false}, + {"b(a)", false}, // function call, not a column + {"('b'::text)", false}, // string literal + {`("a""b" + 1)`, true}, // embedded quote doubled by pg_get_expr + {`("a"b" + 1)`, false}, + {"(b$x + 1)", false}, // $ is part of the identifier + {"(x$b + 1)", false}, + {"((a)::b)", false}, // type cast, not a column + {"(B + 1)", true}, // bare spelling is case-insensitive + {`("B" + 1)`, false}, // a quoted "B" is a different column + {"", false}, + } + for _, c := range cases { + if got := exprReferencesAnyColumn(c.expr, cols); got != c.want { + t.Errorf("exprReferencesAnyColumn(%q) = %v, want %v", c.expr, got, c.want) + } + } +} diff --git a/internal/diff/table.go b/internal/diff/table.go index 95562051..478406ac 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -89,7 +89,9 @@ func sortConstraintColumnsByPosition(columns []*ir.ConstraintColumn) []*ir.Const } // diffTriggers compares triggers between two tables and populates the diff -func diffTriggers(oldTable, newTable *ir.Table, diff *tableDiff) { +// recreatedColumns names the columns applied as DROP COLUMN + ADD COLUMN; a +// trigger depending on one of them is dropped and created again. (#591) +func diffTriggers(oldTable, newTable *ir.Table, diff *tableDiff, recreatedColumns map[string]bool) { oldTriggers := make(map[string]*ir.Trigger) newTriggers := make(map[string]*ir.Trigger) @@ -122,6 +124,15 @@ func diffTriggers(oldTable, newTable *ir.Table, diff *tableDiff) { // Find modified triggers (structural changes, comment-only, or enabled-state-only) for name, newTrigger := range newTriggers { if oldTrigger, exists := oldTriggers[name]; exists { + // A trigger whose WHEN condition or UPDATE OF list names a column + // being re-created blocks the DROP COLUMN (SQLSTATE 2BP01). It is + // dropped in the drop phase and created again from the desired + // state after the column is back. (#591) + if triggerReferencesColumns(oldTrigger, recreatedColumns) { + diff.DroppedTriggers = append(diff.DroppedTriggers, oldTrigger) + diff.AddedTriggers = append(diff.AddedTriggers, newTrigger) + continue + } structurallyEqual := triggersEqual(oldTrigger, newTrigger) commentChanged := oldTrigger.Comment != newTrigger.Comment enabledChanged := oldTrigger.Disabled != newTrigger.Disabled @@ -137,7 +148,10 @@ func diffTriggers(oldTable, newTable *ir.Table, diff *tableDiff) { // diffTables compares two tables and returns the differences // targetSchema is used to normalize type names before comparison -func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { +// targetMajorVersion is the target database's PostgreSQL major version (0 if +// unknown); it decides whether a generated-column expression change can use +// SET EXPRESSION AS (PG17+) or must re-create the column (issue #591). +func diffTables(oldTable, newTable *ir.Table, targetSchema string, targetMajorVersion int) *tableDiff { diff := &tableDiff{ Table: newTable, AddedColumns: []*ir.Column{}, @@ -184,10 +198,19 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { } } - // Find modified columns + // Find modified columns. A generation-clause change that PostgreSQL cannot + // ALTER in place is applied as DROP COLUMN + ADD COLUMN instead (issue #591); + // recreatedColumns drives the re-creation of their dependent objects below. + recreatedColumns := make(map[string]bool) for name, newColumn := range newColumns { if oldColumn, exists := oldColumns[name]; exists { if !columnsEqual(oldColumn, newColumn, targetSchema) { + if generatedColumnNeedsRecreate(oldColumn, newColumn, targetMajorVersion) { + diff.DroppedColumns = append(diff.DroppedColumns, oldColumn) + diff.AddedColumns = append(diff.AddedColumns, newColumn) + recreatedColumns[name] = true + continue + } diff.ModifiedColumns = append(diff.ModifiedColumns, &ColumnDiff{ Old: oldColumn, New: newColumn, @@ -196,6 +219,10 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { } } + if len(recreatedColumns) > 0 { + diff.RecreatedColumns = recreatedColumns + } + // Compare constraints oldConstraints := make(map[string]*ir.Constraint) newConstraints := make(map[string]*ir.Constraint) @@ -229,6 +256,21 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { // Find modified constraints for name, newConstraint := range newConstraints { if oldConstraint, exists := oldConstraints[name]; exists { + // A constraint on a re-created column goes away with DROP COLUMN, so + // it must be added back once the column exists again. Listing it as + // dropped (the DROP statement itself is skipped, see + // constraintDroppedWithColumns) also lets + // planFKRecreationForReplacedConstraints re-bind foreign keys that + // depend on a unique/PK constraint among them. (#591) + // An EXCLUDE constraint can reference the column only through an + // expression, which conkey records as 0, so its definition text is + // checked as well; its DROP is then emitted explicitly (before the + // column drop) because constraintDroppedWithColumns will not skip it. + if constraintDroppedWithColumns(newConstraint, recreatedColumns) || exclusionReferencesColumns(oldConstraint, recreatedColumns) { + diff.DroppedConstraints = append(diff.DroppedConstraints, oldConstraint) + diff.AddedConstraints = append(diff.AddedConstraints, newConstraint) + continue + } if !constraintsEqual(oldConstraint, newConstraint) { diff.ModifiedConstraints = append(diff.ModifiedConstraints, &ConstraintDiff{ Old: oldConstraint, @@ -267,6 +309,17 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { // Find modified indexes (comment changes and structural changes) for name, newIndex := range newIndexes { if oldIndex, exists := oldIndexes[name]; exists { + // DROP COLUMN removes every index on a re-created column, so the + // desired-state index is created afresh afterwards. The old + // definition decides, and it is dropped explicitly as well: the + // DROP uses IF EXISTS, so it is a no-op when the column drop + // already took the index, and it still removes the old index + // when the textual dependency check was a false positive. (#591) + if indexReferencesColumns(oldIndex, recreatedColumns) { + diff.DroppedIndexes = append(diff.DroppedIndexes, oldIndex) + diff.AddedIndexes = append(diff.AddedIndexes, newIndex) + continue + } structurallyEqual := indexesStructurallyEqual(oldIndex, newIndex) commentChanged := oldIndex.Comment != newIndex.Comment @@ -285,7 +338,7 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { } // Compare triggers - diffTriggers(oldTable, newTable, diff) + diffTriggers(oldTable, newTable, diff, recreatedColumns) // Compare policies oldPolicies := make(map[string]*ir.RLSPolicy) @@ -320,6 +373,14 @@ func diffTables(oldTable, newTable *ir.Table, targetSchema string) *tableDiff { // Find modified policies for name, newPolicy := range newPolicies { if oldPolicy, exists := oldPolicies[name]; exists { + // A policy whose expressions name a column being re-created blocks + // the DROP COLUMN (SQLSTATE 2BP01); it is dropped before the column + // and created again from the desired state afterwards. (#591) + if policyReferencesColumns(oldPolicy, recreatedColumns) { + diff.DroppedPolicies = append(diff.DroppedPolicies, oldPolicy) + diff.AddedPolicies = append(diff.AddedPolicies, newPolicy) + continue + } if !policiesEqual(oldPolicy, newPolicy) { diff.ModifiedPolicies = append(diff.ModifiedPolicies, &policyDiff{ Old: oldPolicy, @@ -397,7 +458,7 @@ func diffExternalTable(oldTable, newTable *ir.Table) *tableDiff { } // For external tables, only compare triggers (not table structure) - diffTriggers(oldTable, newTable, diff) + diffTriggers(oldTable, newTable, diff, nil) // Return nil if no trigger changes if len(diff.AddedTriggers) == 0 && len(diff.DroppedTriggers) == 0 && len(diff.ModifiedTriggers) == 0 { @@ -567,7 +628,9 @@ func generateDeferredConstraintsSQL(deferred []*deferredConstraint, targetSchema } // generateModifyTablesSQL generates ALTER TABLE statements -func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, fkPreDrops []*ir.Constraint, targetSchema string, collector *diffCollector) { +// deferredFKs holds "schema.table.constraint" keys of added foreign keys that +// are emitted by the post-add step instead of with their table's changes. +func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, fkPreDrops []*ir.Constraint, deferredFKs map[string]bool, targetSchema string, collector *diffCollector) { // Build a set of tables being dropped (CASCADE will remove their dependent FK constraints) droppedTableSet := make(map[string]bool, len(droppedTables)) for _, t := range droppedTables { @@ -590,7 +653,7 @@ func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, fkPr } // Pass collector to generateAlterTableStatements to collect with proper context - diff.generateAlterTableStatements(targetSchema, collector, droppedTableSet, droppedColumnSet, preDroppedFKSet) + diff.generateAlterTableStatements(targetSchema, collector, droppedTableSet, droppedColumnSet, preDroppedFKSet, deferredFKs) } } @@ -618,8 +681,20 @@ func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, fkPr func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTables []*ir.Table, oldTables, newTables map[string]*ir.Table) (preDrops []*ir.Constraint, postAdds []*deferredConstraint, suppressedInlineFKs map[string]bool) { // Unique/PK constraints removed by this migration, keyed by their table replaced := make(map[string][]*ir.Constraint) + // Standalone unique indexes that DROP COLUMN removes along with a + // re-created column; a foreign key can be bound to such an index just like + // to a unique constraint. (#591) + replacedUniqueIndexes := make(map[string][]*ir.Index) for _, td := range modifiedTables { key := td.Table.Schema + "." + td.Table.Name + if oldTable := oldTables[key]; oldTable != nil && len(td.RecreatedColumns) > 0 { + for _, name := range sortedKeys(oldTable.Indexes) { + idx := oldTable.Indexes[name] + if idx.Type == ir.IndexTypeUnique && !idx.IsPartial && indexReferencesColumns(idx, td.RecreatedColumns) { + replacedUniqueIndexes[key] = append(replacedUniqueIndexes[key], idx) + } + } + } for _, c := range td.DroppedConstraints { if c.Type == ir.ConstraintTypeUnique || c.Type == ir.ConstraintTypePrimaryKey { replaced[key] = append(replaced[key], c) @@ -652,7 +727,7 @@ func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTa } } - if len(replaced) == 0 && len(addedConstraints) == 0 && len(addedUniqueIndexes) == 0 { + if len(replaced) == 0 && len(replacedUniqueIndexes) == 0 && len(addedConstraints) == 0 && len(addedUniqueIndexes) == 0 { return nil, nil, nil } @@ -669,12 +744,15 @@ func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTa continue } newFK := newTable.Constraints[name] - oldBound := fkReferencesAnyConstraint(fk, replaced[fkReferencedTableKey(fk)]) + oldBound := fkReferencesAnyConstraint(fk, replaced[fkReferencedTableKey(fk)]) || + fkReferencesAnyUniqueIndex(fk, replacedUniqueIndexes[fkReferencedTableKey(fk)]) // A changed FK whose new definition targets a replaced constraint // must also wait for the replacement, even if its old definition // was bound elsewhere. newBound := newFK != nil && !constraintsEqual(fk, newFK) && - fkReferencesAnyConstraint(newFK, replaced[fkReferencedTableKey(newFK)]) + (fkReferencesAnyConstraint(newFK, replaced[fkReferencedTableKey(newFK)]) || + fkReferencesAnyUniqueIndex(newFK, replacedUniqueIndexes[fkReferencedTableKey(newFK)]) || + fkReferencesAnyUniqueIndex(newFK, addedUniqueIndexes[fkReferencedTableKey(newFK)])) if !oldBound && !newBound { continue } @@ -698,6 +776,7 @@ func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTa } refKey := fkReferencedTableKey(fk) if !fkReferencesAnyConstraint(fk, replaced[refKey]) && + !fkReferencesAnyUniqueIndex(fk, replacedUniqueIndexes[refKey]) && !fkReferencesAnyConstraint(fk, addedConstraints[refKey]) && !fkReferencesAnyUniqueIndex(fk, addedUniqueIndexes[refKey]) { continue @@ -707,6 +786,26 @@ func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTa } } + // FKs newly added to existing tables that target a unique index this + // migration creates, whether rebuilt with a re-created column or new: the + // ALTER TABLE ... ADD CONSTRAINT would run before the index exists + // (always for a self-reference, and depending on table order otherwise), + // so they are deferred the same way. (#591, #506) + for _, td := range modifiedTables { + for _, fk := range td.AddedConstraints { + if fk.Type != ir.ConstraintTypeForeignKey { + continue + } + refKey := fkReferencedTableKey(fk) + if !fkReferencesAnyUniqueIndex(fk, replacedUniqueIndexes[refKey]) && + !fkReferencesAnyUniqueIndex(fk, addedUniqueIndexes[refKey]) { + continue + } + suppressedInlineFKs[constraintPathKey(fk)] = true + postAdds = append(postAdds, &deferredConstraint{table: td.Table, constraint: fk}) + } + } + sort.Slice(preDrops, func(i, j int) bool { return constraintPathKey(preDrops[i]) < constraintPathKey(preDrops[j]) }) @@ -1044,6 +1143,72 @@ func constraintDroppedWithColumns(constraint *ir.Constraint, droppedColumnSet ma return false } +// collectDropPolicy emits DROP POLICY for a policy of this table. +func (td *tableDiff) collectDropPolicy(policy *ir.RLSPolicy, targetSchema string, collector *diffCollector) { + tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema) + sql := fmt.Sprintf("DROP POLICY IF EXISTS %s ON %s;", ir.QuoteIdentifier(policy.Name), tableName) + + context := &diffContext{ + Type: DiffTypeTablePolicy, + Operation: DiffOperationDrop, + Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, policy.Name), + Source: policy, + CanRunInTransaction: true, + } + collector.collect(context, sql) +} + +// exclusionReferencesColumns reports whether an EXCLUDE constraint's +// definition names any of the given columns inside an expression element, +// which pg_constraint.conkey does not record. (#591) +func exclusionReferencesColumns(constraint *ir.Constraint, columns map[string]bool) bool { + return constraint != nil && constraint.Type == ir.ConstraintTypeExclusion && + exprReferencesAnyColumn(constraint.ExclusionDefinition, columns) +} + +// policyReferencesColumns reports whether a policy's USING or WITH CHECK +// expression names any of the given columns. (#591) +func policyReferencesColumns(policy *ir.RLSPolicy, columns map[string]bool) bool { + return policy != nil && (exprReferencesAnyColumn(policy.Using, columns) || exprReferencesAnyColumn(policy.WithCheck, columns)) +} + +// triggerReferencesColumns reports whether a trigger depends on any of the +// given columns through its UPDATE OF list or WHEN condition. (#591) +func triggerReferencesColumns(trigger *ir.Trigger, columns map[string]bool) bool { + if trigger == nil || len(columns) == 0 { + return false + } + for _, name := range trigger.UpdateColumns { + if columns[name] { + return true + } + } + return exprReferencesAnyColumn(trigger.Condition, columns) +} + +// indexReferencesColumns reports whether an index depends on any of the given +// columns, i.e. whether ALTER TABLE ... DROP COLUMN of one of them removes it: +// as a key or INCLUDE column, inside an expression, or in the partial-index +// predicate. (#591) +func indexReferencesColumns(index *ir.Index, columns map[string]bool) bool { + if index == nil || len(columns) == 0 { + return false + } + for _, col := range index.Columns { + // pg_get_indexdef renders a key column as its (possibly quoted) name + // and an expression column as the expression text. + if columns[col.Name] || exprReferencesAnyColumn(col.Name, columns) { + return true + } + } + for _, name := range index.IncludeColumns { + if columns[name] || exprReferencesAnyColumn(name, columns) { + return true + } + } + return index.IsPartial && exprReferencesAnyColumn(index.Where, columns) +} + // generateAlterTableStatements generates SQL statements for table modifications // Note: DroppedTriggers are skipped here because they are already processed in the DROP phase // (see generateDropTriggersFromModifiedTables in trigger.go) @@ -1054,7 +1219,10 @@ func constraintDroppedWithColumns(constraint *ir.Constraint, droppedColumnSet ma // preDroppedFKSet contains "schema.table.constraint" keys for FKs already dropped in the // pre-drop step because they were bound to a replaced unique/PK constraint; their drop // and modify entries are skipped since the pre-drop/post-add steps handle them. (#439) -func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector *diffCollector, droppedTableSet map[string]bool, droppedColumnSet map[string]bool, preDroppedFKSet map[string]bool) { +// deferredFKSet contains "schema.table.constraint" keys of added FKs emitted by the +// post-add step (they target a key that is rebuilt in this migration); their normal +// and inline emission is skipped. (#591) +func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector *diffCollector, droppedTableSet map[string]bool, droppedColumnSet map[string]bool, preDroppedFKSet map[string]bool, deferredFKSet map[string]bool) { // Persistence change (UNLOGGED to LOGGED or vice versa) should emit first // because PostgreSQL rewrites the heap so doing it before column/constraint // changes reduces data movement on subsequent steps @@ -1076,6 +1244,15 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector collector.collect(context, sql) } + // Policies that reference a column being re-created must go before the + // DROP COLUMN they would otherwise block; the remaining policy drops keep + // their usual place after the RLS changes below. (#591) + for _, policy := range td.DroppedPolicies { + if policyReferencesColumns(policy, td.RecreatedColumns) { + td.collectDropPolicy(policy, targetSchema, collector) + } + } + // Drop constraints first (before dropping columns) - already sorted by the Diff operation for _, constraint := range td.DroppedConstraints { // Skip constraints already removed by a dropped column. (#384) @@ -1161,6 +1338,10 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector // Check for single-column constraints that can be added inline var inlineConstraint string for _, constraint := range td.AddedConstraints { + // Deferred FKs are emitted by the post-add step. (#591) + if deferredFKSet[constraintPathKey(constraint)] { + continue + } // Only add inline for single-column constraints if len(constraint.Columns) == 1 && constraint.Columns[0].Name == column.Name { switch constraint.Type { @@ -1260,6 +1441,10 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector if inlineConstraints[constraint.Name] { continue } + // Deferred FKs are emitted by the post-add step. (#591) + if deferredFKSet[constraintPathKey(constraint)] { + continue + } switch constraint.Type { case ir.ConstraintTypeUnique: @@ -1523,17 +1708,11 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector // Drop policies - already sorted by the Diff operation for _, policy := range td.DroppedPolicies { - tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema) - sql := fmt.Sprintf("DROP POLICY IF EXISTS %s ON %s;", ir.QuoteIdentifier(policy.Name), tableName) - - context := &diffContext{ - Type: DiffTypeTablePolicy, - Operation: DiffOperationDrop, - Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, policy.Name), - Source: policy, - CanRunInTransaction: true, + // Already dropped ahead of the column it depends on. (#591) + if policyReferencesColumns(policy, td.RecreatedColumns) { + continue } - collector.collect(context, sql) + td.collectDropPolicy(policy, targetSchema, collector) } // Drop triggers - skipped here because they are already dropped in the DROP phase diff --git a/internal/diff/view.go b/internal/diff/view.go index 464de616..cd7e5252 100644 --- a/internal/diff/view.go +++ b/internal/diff/view.go @@ -2,7 +2,6 @@ package diff import ( "fmt" - "regexp" "sort" "strings" @@ -806,36 +805,6 @@ func viewDependsOnView(viewA *ir.View, viewBName string) bool { return containsIdentifier(viewA.Definition, viewBName) } -// containsIdentifier checks if the given SQL text contains the identifier as a whole word. -// This uses word boundary matching to avoid false positives (e.g., "user" matching "users"). -func containsIdentifier(sqlText, identifier string) bool { - if sqlText == "" || identifier == "" { - return false - } - - // Build a regex pattern that matches the identifier as a whole word. - // Word boundaries in SQL are: start/end of string, whitespace, punctuation, operators. - // We use a pattern that matches the identifier not preceded/followed by word characters. - // - // For schema-qualified identifiers (containing a dot), treat '.' as part of the word - // to avoid matching inside longer qualified paths like "other.schema.name". - // Use [^a-zA-Z0-9_] to exclude both upper and lowercase letters as word boundaries. - var pattern string - if strings.Contains(identifier, ".") { - pattern = `(?i)(?:^|[^a-zA-Z0-9_.])` + regexp.QuoteMeta(identifier) + `(?:[^a-zA-Z0-9_.]|$)` - } else { - pattern = `(?i)(?:^|[^a-zA-Z0-9_])` + regexp.QuoteMeta(identifier) + `(?:[^a-zA-Z0-9_]|$)` - } - matched, err := regexp.MatchString(pattern, sqlText) - if err != nil { - // This should never happen since regexp.QuoteMeta ensures valid pattern, - // but log it rather than silently ignoring - fmt.Printf("containsIdentifier: regexp error for pattern %q: %v\n", pattern, err) - return false - } - return matched -} - // viewDependsOnTable checks if a view depends on a specific table // by checking if the table name appears in the view definition. // Uses whole-word identifier matching (not plain substring) so that a table @@ -851,7 +820,7 @@ func viewDependsOnTable(view *ir.View, tableSchema, tableName string) bool { } // Check for qualified table name (schema.table) - if containsIdentifier(view.Definition, tableSchema+"."+tableName) { + if containsQualifiedIdentifier(view.Definition, tableSchema, tableName) { return true } diff --git a/ir/normalize.go b/ir/normalize.go index 156eec75..f4ccdfb8 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -186,7 +186,19 @@ func normalizeTable(table *Table) { // normalizeColumn normalizes column default values // tableSchema is used to strip same-schema qualifiers from function calls func normalizeColumn(column *Column, tableSchema string) { - if column == nil || column.DefaultValue == nil { + if column == nil { + return + } + + // pg_get_expr qualifies same-schema functions and types in a generation + // expression depending on the inspecting session's search_path; strip the + // qualifier so current and desired state compare textually (issue #591). + if column.GeneratedExpr != nil && tableSchema != "" { + stripped := StripSchemaPrefixFromBody(*column.GeneratedExpr, tableSchema) + column.GeneratedExpr = &stripped + } + + if column.DefaultValue == nil { return } @@ -435,12 +447,23 @@ func normalizeFunctionDefinition(def string) string { // StripSchemaPrefixFromBody removes the current schema qualifier from identifiers // in a function or procedure body. For example, "public.users" becomes "users". // It skips single-quoted string literals to avoid modifying string constants. +// A schema name that needs quoting is recognized in the form the deparsers +// render it, e.g. "My Schema".calc(a) becomes calc(a). func StripSchemaPrefixFromBody(body, schema string) string { if body == "" || schema == "" { return body } - prefix := schema + "." + if quoted := QuoteIdentifier(schema); quoted != schema { + body = stripSchemaPrefixOccurrences(body, quoted+".") + } + return stripSchemaPrefixOccurrences(body, schema+".") +} + +// stripSchemaPrefixOccurrences removes every occurrence of prefix ("schema." +// in bare or quote_ident form) that starts an identifier reference outside +// string literals, quoting the remaining identifier when it is a reserved word. +func stripSchemaPrefixOccurrences(body, prefix string) string { prefixLen := len(prefix) // Fast path: if the prefix doesn't appear at all, return as-is @@ -472,6 +495,29 @@ func StripSchemaPrefixFromBody(body, schema string) string { continue } + // A double-quoted identifier is copied verbatim unless it is the quoted + // schema token itself (the prefix check below runs first for that), so + // a column literally named "public.foo" keeps its name. + if !inString && ch == '"' && !(i+prefixLen <= len(body) && body[i:i+prefixLen] == prefix) { + end := i + 1 + for end < len(body) { + if body[end] == '"' { + if end+1 < len(body) && body[end+1] == '"' { + end += 2 + continue + } + break + } + end++ + } + if end >= len(body) { + end = len(body) - 1 + } + result.WriteString(body[i : end+1]) + i = end + continue + } + // Only attempt replacement outside string literals if !inString && i+prefixLen <= len(body) && body[i:i+prefixLen] == prefix { // Ensure this is a schema qualifier, not part of a longer identifier diff --git a/ir/normalize_test.go b/ir/normalize_test.go index 09cf9838..7d049bfd 100644 --- a/ir/normalize_test.go +++ b/ir/normalize_test.go @@ -583,7 +583,7 @@ func TestStripRedundantTextCast(t *testing.T) { {"('new'::character varying)::text", "'new'::character varying"}, {"('new'::varchar)::text", "'new'::varchar"}, {"'new'::character varying::text", "'new'::character varying"}, - {"'new'::character varying", "'new'::character varying"}, // already clean + {"'new'::character varying", "'new'::character varying"}, // already clean {"'active'::public.status_type", "'active'::public.status_type"}, // custom type untouched {"42", "42"}, } @@ -669,3 +669,26 @@ func TestMarkSerialColumns(t *testing.T) { } } } + +// A schema name that needs quoting is rendered by the deparsers in quote_ident +// form; the qualifier must be stripped in that form too, or the current and +// desired states of a generated column expression never compare equal (#591). +func TestStripSchemaPrefixFromBody_QuotedSchema(t *testing.T) { + cases := []struct { + body, schema, want string + }{ + {`"My Schema".calc(a)`, "My Schema", `calc(a)`}, + {`("My Schema".calc(a) + "My Schema"."Other"(b))`, "My Schema", `(calc(a) + "Other"(b))`}, + {`'"My Schema".calc'`, "My Schema", `'"My Schema".calc'`}, + {`"my""s".calc(a)`, `my"s`, `calc(a)`}, + {`public.calc(a)`, "public", `calc(a)`}, + {`other.calc(a)`, "My Schema", `other.calc(a)`}, + {`("public.foo" * 2)`, "public", `("public.foo" * 2)`}, // a column literally named public.foo + {`("My Schema".calc("My Schema.x"))`, "My Schema", `(calc("My Schema.x"))`}, // quoted schema token vs quoted column + } + for _, c := range cases { + if got := StripSchemaPrefixFromBody(c.body, c.schema); got != c.want { + t.Errorf("StripSchemaPrefixFromBody(%q, %q) = %q, want %q", c.body, c.schema, got, c.want) + } + } +} diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/diff.sql b/testdata/diff/create_table/issue_591_alter_generated_column/diff.sql new file mode 100644 index 00000000..ef296505 --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/diff.sql @@ -0,0 +1,105 @@ +DROP VIEW IF EXISTS big_orders RESTRICT; + +DROP VIEW IF EXISTS order_totals RESTRICT; + +DROP VIEW IF EXISTS order_labels RESTRICT; + +DROP TRIGGER IF EXISTS orders_total_trg ON orders; + +ALTER TABLE metric_refs DROP CONSTRAINT metric_refs_tripled_fkey; + +ALTER TABLE shipments DROP CONSTRAINT shipments_order_code_fkey; + +ALTER TABLE metrics DROP COLUMN tripled; + +ALTER TABLE metrics +ADD COLUMN tripled integer GENERATED ALWAYS AS ((a * 3)) STORED CONSTRAINT metrics_tripled_key UNIQUE; + +ALTER TABLE metrics ALTER COLUMN doubled SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metrics ALTER COLUMN label DROP EXPRESSION; + +ALTER TABLE metrics ALTER COLUMN label SET DEFAULT 'none'; + +ALTER TABLE metrics +ADD CONSTRAINT metrics_tripled_check CHECK (tripled > 0); + +DROP INDEX IF EXISTS metrics_tripled_idx; + +CREATE INDEX IF NOT EXISTS metrics_tripled_idx ON metrics ((tripled + 1)) WHERE (tripled > 10); + +DROP POLICY IF EXISTS orders_big ON orders; + +ALTER TABLE orders DROP CONSTRAINT orders_total_excl; + +ALTER TABLE orders DROP COLUMN total; + +ALTER TABLE orders DROP COLUMN code; + +ALTER TABLE orders ADD COLUMN total integer GENERATED ALWAYS AS ((qty * price)) STORED; + +ALTER TABLE orders ADD COLUMN code text GENERATED ALWAYS AS (('ORD-'::text || (id)::text)) STORED; + +ALTER TABLE orders +ADD CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =); + +CREATE OR REPLACE TRIGGER orders_total_trg + AFTER UPDATE ON orders + FOR EACH ROW + WHEN (((NEW.total > 0))) + EXECUTE FUNCTION orders_audit(); + +CREATE POLICY orders_big ON orders TO PUBLIC USING (total > 100); + +DROP INDEX IF EXISTS orders_code_key; + +CREATE UNIQUE INDEX IF NOT EXISTS orders_code_key ON orders (code); + +DROP INDEX IF EXISTS orders_lookup_idx; + +CREATE INDEX IF NOT EXISTS orders_lookup_idx ON orders (total); + +CREATE UNIQUE INDEX IF NOT EXISTS orders_total_key ON orders (total); + +ALTER TABLE vt DROP COLUMN v2; + +ALTER TABLE vt DROP COLUMN s1; + +ALTER TABLE vt ADD COLUMN v2 integer; + +ALTER TABLE vt ADD COLUMN s1 integer GENERATED ALWAYS AS ((a * 2)) VIRTUAL; + +ALTER TABLE vt ALTER COLUMN v1 SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metric_refs +ADD CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES metrics (tripled); + +ALTER TABLE returns +ADD CONSTRAINT returns_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code); + +ALTER TABLE returns +ADD CONSTRAINT returns_order_total_fkey FOREIGN KEY (order_total) REFERENCES orders (total); + +ALTER TABLE shipments +ADD CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code); + +CREATE OR REPLACE VIEW order_labels AS + SELECT id, + 'x'::text AS label + FROM orders; + +CREATE OR REPLACE VIEW order_totals AS + SELECT id, + total + FROM orders; + +CREATE OR REPLACE VIEW big_orders AS + SELECT id + FROM order_totals + WHERE total > 100; + +GRANT SELECT ON TABLE order_totals TO app_reader; + +GRANT SELECT (id) ON TABLE big_orders TO app_reader; + +GRANT SELECT (id, total) ON TABLE orders TO app_reader; diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/new.sql b/testdata/diff/create_table/issue_591_alter_generated_column/new.sql new file mode 100644 index 00000000..f112c873 --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/new.sql @@ -0,0 +1,90 @@ +-- Re-created column with dependents: views, policy, trigger, grants, +-- expression EXCLUDE, standalone unique index bound by FKs, moved index +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN + CREATE ROLE app_reader; + END IF; +END $$; + +CREATE FUNCTION public.orders_audit() RETURNS trigger + LANGUAGE plpgsql + AS $$ BEGIN RETURN NEW; END $$; + +CREATE TABLE public.orders ( + id integer PRIMARY KEY, + qty integer NOT NULL, + price integer NOT NULL, + total integer GENERATED ALWAYS AS (qty * price) STORED, + code text GENERATED ALWAYS AS ('ORD-' || id::text) STORED, + CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =) +); + +CREATE UNIQUE INDEX orders_code_key ON public.orders (code); + +CREATE INDEX orders_lookup_idx ON public.orders (total); + +CREATE UNIQUE INDEX orders_total_key ON public.orders (total); + +CREATE TABLE public.shipments ( + id integer PRIMARY KEY, + order_code text, + CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES public.orders (code) +); + +CREATE TABLE public.returns ( + id integer PRIMARY KEY, + order_total integer, + order_code text, + CONSTRAINT returns_order_code_fkey FOREIGN KEY (order_code) REFERENCES public.orders (code), + CONSTRAINT returns_order_total_fkey FOREIGN KEY (order_total) REFERENCES public.orders (total) +); + +CREATE VIEW public.order_totals AS SELECT id, total FROM public.orders; + +CREATE VIEW public.big_orders AS SELECT id FROM public.order_totals WHERE total > 100; + +CREATE VIEW public.order_prices AS SELECT id, price FROM public.orders; + +CREATE VIEW public.order_labels AS SELECT id, 'x'::text AS label FROM public.orders; + +ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY orders_big ON public.orders USING (total > 100); + +CREATE TRIGGER orders_total_trg AFTER UPDATE ON public.orders FOR EACH ROW WHEN (NEW.total > 0) EXECUTE FUNCTION public.orders_audit(); + +GRANT SELECT (id, total) ON public.orders TO app_reader; + +GRANT SELECT ON public.order_totals TO app_reader; + +GRANT SELECT (id) ON public.big_orders TO app_reader; + +-- STORED expression change, STORED -> plain, plain -> STORED with a +-- check constraint, unique constraint bound by an FK, and an expression index +CREATE TABLE public.metrics ( + id integer PRIMARY KEY, + a integer NOT NULL, + doubled integer GENERATED ALWAYS AS (a * 2) STORED, + label text DEFAULT 'none', + tripled integer GENERATED ALWAYS AS (a * 3) STORED, + CONSTRAINT metrics_tripled_check CHECK (tripled > 0), + CONSTRAINT metrics_tripled_key UNIQUE (tripled) +); + +CREATE INDEX metrics_doubled_idx ON public.metrics (doubled); + +CREATE INDEX metrics_tripled_idx ON public.metrics ((tripled + 1)) WHERE tripled > 10; + +CREATE TABLE public.metric_refs ( + tripled integer, + CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES public.metrics (tripled) +); + +-- VIRTUAL expression change, VIRTUAL -> plain, STORED -> VIRTUAL (PG18+) +CREATE TABLE public.vt ( + a integer NOT NULL, + v1 integer GENERATED ALWAYS AS (a * 2) VIRTUAL, + v2 integer, + s1 integer GENERATED ALWAYS AS (a * 2) VIRTUAL +); diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/old.sql b/testdata/diff/create_table/issue_591_alter_generated_column/old.sql new file mode 100644 index 00000000..6ae837af --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/old.sql @@ -0,0 +1,86 @@ +-- Re-created column with dependents: views, policy, trigger, grants, +-- expression EXCLUDE, standalone unique index bound by FKs, moved index +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN + CREATE ROLE app_reader; + END IF; +END $$; + +CREATE FUNCTION public.orders_audit() RETURNS trigger + LANGUAGE plpgsql + AS $$ BEGIN RETURN NEW; END $$; + +CREATE TABLE public.orders ( + id integer PRIMARY KEY, + qty integer NOT NULL, + price integer NOT NULL, + total integer, + code text, + CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =) +); + +CREATE UNIQUE INDEX orders_code_key ON public.orders (code); + +CREATE INDEX orders_lookup_idx ON public.orders (price); + +CREATE TABLE public.shipments ( + id integer PRIMARY KEY, + order_code text, + CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES public.orders (code) +); + +CREATE TABLE public.returns ( + id integer PRIMARY KEY, + order_total integer, + order_code text +); + +CREATE VIEW public.order_totals AS SELECT id, total FROM public.orders; + +CREATE VIEW public.big_orders AS SELECT id FROM public.order_totals WHERE total > 100; + +CREATE VIEW public.order_prices AS SELECT id, price FROM public.orders; + +CREATE VIEW public.order_labels AS SELECT id, code AS label FROM public.orders; + +ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY orders_big ON public.orders USING (total > 100); + +CREATE TRIGGER orders_total_trg AFTER UPDATE ON public.orders FOR EACH ROW WHEN (NEW.total > 0) EXECUTE FUNCTION public.orders_audit(); + +GRANT SELECT (id, total) ON public.orders TO app_reader; + +GRANT SELECT ON public.order_totals TO app_reader; + +GRANT SELECT (id) ON public.big_orders TO app_reader; + +-- STORED expression change, STORED -> plain, plain -> STORED with a +-- check constraint, unique constraint bound by an FK, and an expression index +CREATE TABLE public.metrics ( + id integer PRIMARY KEY, + a integer NOT NULL, + doubled integer GENERATED ALWAYS AS (a * 3) STORED, + label text GENERATED ALWAYS AS ('n=' || a) STORED, + tripled integer, + CONSTRAINT metrics_tripled_check CHECK (tripled > 0), + CONSTRAINT metrics_tripled_key UNIQUE (tripled) +); + +CREATE INDEX metrics_doubled_idx ON public.metrics (doubled); + +CREATE INDEX metrics_tripled_idx ON public.metrics ((tripled + 1)) WHERE tripled > 10; + +CREATE TABLE public.metric_refs ( + tripled integer, + CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES public.metrics (tripled) +); + +-- VIRTUAL expression change, VIRTUAL -> plain, STORED -> VIRTUAL (PG18+) +CREATE TABLE public.vt ( + a integer NOT NULL, + v1 integer GENERATED ALWAYS AS (a * 3) VIRTUAL, + v2 integer GENERATED ALWAYS AS (a * 2) VIRTUAL, + s1 integer GENERATED ALWAYS AS (a * 2) STORED +); diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/plan.json b/testdata/diff/create_table/issue_591_alter_generated_column/plan.json new file mode 100644 index 00000000..82d44211 --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/plan.json @@ -0,0 +1,444 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.13.0", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "09ffd86ab854259a56fd900c98b031b50b0f4222351115dab779a7bddf3fe0ac" + }, + "groups": [ + { + "steps": [ + { + "sql": "DROP VIEW IF EXISTS big_orders RESTRICT;", + "type": "view", + "operation": "recreate", + "path": "public.big_orders" + }, + { + "sql": "DROP VIEW IF EXISTS order_totals RESTRICT;", + "type": "view", + "operation": "recreate", + "path": "public.order_totals" + }, + { + "sql": "DROP VIEW IF EXISTS order_labels RESTRICT;", + "type": "view", + "operation": "recreate", + "path": "public.order_labels" + }, + { + "sql": "DROP TRIGGER IF EXISTS orders_total_trg ON orders;", + "type": "table.trigger", + "operation": "drop", + "path": "public.orders.orders_total_trg" + }, + { + "sql": "ALTER TABLE metric_refs DROP CONSTRAINT metric_refs_tripled_fkey;", + "type": "table.constraint", + "operation": "drop", + "path": "public.metric_refs.metric_refs_tripled_fkey" + }, + { + "sql": "ALTER TABLE shipments DROP CONSTRAINT shipments_order_code_fkey;", + "type": "table.constraint", + "operation": "drop", + "path": "public.shipments.shipments_order_code_fkey" + }, + { + "sql": "ALTER TABLE metrics DROP COLUMN tripled;", + "type": "table.column", + "operation": "drop", + "path": "public.metrics.tripled" + }, + { + "sql": "ALTER TABLE metrics\nADD COLUMN tripled integer GENERATED ALWAYS AS ((a * 3)) STORED CONSTRAINT metrics_tripled_key UNIQUE;", + "type": "table.column", + "operation": "create", + "path": "public.metrics.tripled" + }, + { + "sql": "ALTER TABLE metrics ALTER COLUMN doubled SET EXPRESSION AS ((a * 2));", + "type": "table.column", + "operation": "alter", + "path": "public.metrics.doubled" + }, + { + "sql": "ALTER TABLE metrics ALTER COLUMN label DROP EXPRESSION;", + "type": "table.column", + "operation": "alter", + "path": "public.metrics.label" + }, + { + "sql": "ALTER TABLE metrics ALTER COLUMN label SET DEFAULT 'none';", + "type": "table.column", + "operation": "alter", + "path": "public.metrics.label" + }, + { + "sql": "ALTER TABLE metrics\nADD CONSTRAINT metrics_tripled_check CHECK (tripled > 0) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.metrics.metrics_tripled_check" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE metrics VALIDATE CONSTRAINT metrics_tripled_check;", + "type": "table.constraint", + "operation": "create", + "path": "public.metrics.metrics_tripled_check" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS metrics_tripled_idx_pgschema_new ON metrics ((tripled + 1)) WHERE (tripled > 10);", + "type": "table.index", + "operation": "alter", + "path": "public.metrics.metrics_tripled_idx" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'metrics_tripled_idx_pgschema_new';", + "directive": { + "type": "wait", + "message": "Creating index metrics_tripled_idx_pgschema_new" + }, + "type": "table.index", + "operation": "alter", + "path": "public.metrics.metrics_tripled_idx" + } + ] + }, + { + "steps": [ + { + "sql": "DROP INDEX IF EXISTS metrics_tripled_idx;", + "type": "table.index", + "operation": "alter", + "path": "public.metrics.metrics_tripled_idx" + }, + { + "sql": "ALTER INDEX metrics_tripled_idx_pgschema_new RENAME TO metrics_tripled_idx;", + "type": "table.index", + "operation": "alter", + "path": "public.metrics.metrics_tripled_idx" + }, + { + "sql": "DROP POLICY IF EXISTS orders_big ON orders;", + "type": "table.policy", + "operation": "drop", + "path": "public.orders.orders_big" + }, + { + "sql": "ALTER TABLE orders DROP CONSTRAINT orders_total_excl;", + "type": "table.constraint", + "operation": "drop", + "path": "public.orders.orders_total_excl" + }, + { + "sql": "ALTER TABLE orders DROP COLUMN total;", + "type": "table.column", + "operation": "drop", + "path": "public.orders.total" + }, + { + "sql": "ALTER TABLE orders DROP COLUMN code;", + "type": "table.column", + "operation": "drop", + "path": "public.orders.code" + }, + { + "sql": "ALTER TABLE orders ADD COLUMN total integer GENERATED ALWAYS AS ((qty * price)) STORED;", + "type": "table.column", + "operation": "create", + "path": "public.orders.total" + }, + { + "sql": "ALTER TABLE orders ADD COLUMN code text GENERATED ALWAYS AS (('ORD-'::text || (id)::text)) STORED;", + "type": "table.column", + "operation": "create", + "path": "public.orders.code" + }, + { + "sql": "ALTER TABLE orders\nADD CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =);", + "type": "table.constraint", + "operation": "create", + "path": "public.orders.orders_total_excl" + }, + { + "sql": "CREATE OR REPLACE TRIGGER orders_total_trg\n AFTER UPDATE ON orders\n FOR EACH ROW\n WHEN (((NEW.total > 0)))\n EXECUTE FUNCTION orders_audit();", + "type": "table.trigger", + "operation": "create", + "path": "public.orders.orders_total_trg" + }, + { + "sql": "CREATE POLICY orders_big ON orders TO PUBLIC USING (total > 100);", + "type": "table.policy", + "operation": "create", + "path": "public.orders.orders_big" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_code_key_pgschema_new ON orders (code);", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_code_key" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'orders_code_key_pgschema_new';", + "directive": { + "type": "wait", + "message": "Creating index orders_code_key_pgschema_new" + }, + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_code_key" + } + ] + }, + { + "steps": [ + { + "sql": "DROP INDEX IF EXISTS orders_code_key;", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_code_key" + }, + { + "sql": "ALTER INDEX orders_code_key_pgschema_new RENAME TO orders_code_key;", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_code_key" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_lookup_idx_pgschema_new ON orders (total);", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_lookup_idx" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'orders_lookup_idx_pgschema_new';", + "directive": { + "type": "wait", + "message": "Creating index orders_lookup_idx_pgschema_new" + }, + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_lookup_idx" + } + ] + }, + { + "steps": [ + { + "sql": "DROP INDEX IF EXISTS orders_lookup_idx;", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_lookup_idx" + }, + { + "sql": "ALTER INDEX orders_lookup_idx_pgschema_new RENAME TO orders_lookup_idx;", + "type": "table.index", + "operation": "alter", + "path": "public.orders.orders_lookup_idx" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_total_key ON orders (total);", + "type": "table.index", + "operation": "create", + "path": "public.orders.orders_total_key" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'orders_total_key';", + "directive": { + "type": "wait", + "message": "Creating index orders_total_key" + }, + "type": "table.index", + "operation": "create", + "path": "public.orders.orders_total_key" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE vt DROP COLUMN v2;", + "type": "table.column", + "operation": "drop", + "path": "public.vt.v2" + }, + { + "sql": "ALTER TABLE vt DROP COLUMN s1;", + "type": "table.column", + "operation": "drop", + "path": "public.vt.s1" + }, + { + "sql": "ALTER TABLE vt ADD COLUMN v2 integer;", + "type": "table.column", + "operation": "create", + "path": "public.vt.v2" + }, + { + "sql": "ALTER TABLE vt ADD COLUMN s1 integer GENERATED ALWAYS AS ((a * 2)) VIRTUAL;", + "type": "table.column", + "operation": "create", + "path": "public.vt.s1" + }, + { + "sql": "ALTER TABLE vt ALTER COLUMN v1 SET EXPRESSION AS ((a * 2));", + "type": "table.column", + "operation": "alter", + "path": "public.vt.v1" + }, + { + "sql": "ALTER TABLE metric_refs\nADD CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES metrics (tripled) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.metric_refs.metric_refs_tripled_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE metric_refs VALIDATE CONSTRAINT metric_refs_tripled_fkey;", + "type": "table.constraint", + "operation": "create", + "path": "public.metric_refs.metric_refs_tripled_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE returns\nADD CONSTRAINT returns_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.returns.returns_order_code_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE returns VALIDATE CONSTRAINT returns_order_code_fkey;", + "type": "table.constraint", + "operation": "create", + "path": "public.returns.returns_order_code_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE returns\nADD CONSTRAINT returns_order_total_fkey FOREIGN KEY (order_total) REFERENCES orders (total) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.returns.returns_order_total_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE returns VALIDATE CONSTRAINT returns_order_total_fkey;", + "type": "table.constraint", + "operation": "create", + "path": "public.returns.returns_order_total_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE shipments\nADD CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID;", + "type": "table.constraint", + "operation": "create", + "path": "public.shipments.shipments_order_code_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "ALTER TABLE shipments VALIDATE CONSTRAINT shipments_order_code_fkey;", + "type": "table.constraint", + "operation": "create", + "path": "public.shipments.shipments_order_code_fkey" + } + ] + }, + { + "steps": [ + { + "sql": "CREATE OR REPLACE VIEW order_labels AS\n SELECT id,\n 'x'::text AS label\n FROM orders;", + "type": "view", + "operation": "create", + "path": "public.order_labels" + }, + { + "sql": "CREATE OR REPLACE VIEW order_totals AS\n SELECT id,\n total\n FROM orders;", + "type": "view", + "operation": "create", + "path": "public.order_totals" + }, + { + "sql": "CREATE OR REPLACE VIEW big_orders AS\n SELECT id\n FROM order_totals\n WHERE total > 100;", + "type": "view", + "operation": "recreate", + "path": "public.big_orders" + }, + { + "sql": "GRANT SELECT ON TABLE order_totals TO app_reader;", + "type": "privilege", + "operation": "create", + "path": "privileges.VIEW.order_totals.app_reader" + }, + { + "sql": "GRANT SELECT (id) ON TABLE big_orders TO app_reader;", + "type": "column_privilege", + "operation": "create", + "path": "column_privileges.TABLE.big_orders.id.app_reader" + }, + { + "sql": "GRANT SELECT (id, total) ON TABLE orders TO app_reader;", + "type": "column_privilege", + "operation": "create", + "path": "column_privileges.TABLE.orders.id,total.app_reader" + } + ] + } + ] +} diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/plan.sql b/testdata/diff/create_table/issue_591_alter_generated_column/plan.sql new file mode 100644 index 00000000..11011322 --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/plan.sql @@ -0,0 +1,169 @@ +DROP VIEW IF EXISTS big_orders RESTRICT; + +DROP VIEW IF EXISTS order_totals RESTRICT; + +DROP VIEW IF EXISTS order_labels RESTRICT; + +DROP TRIGGER IF EXISTS orders_total_trg ON orders; + +ALTER TABLE metric_refs DROP CONSTRAINT metric_refs_tripled_fkey; + +ALTER TABLE shipments DROP CONSTRAINT shipments_order_code_fkey; + +ALTER TABLE metrics DROP COLUMN tripled; + +ALTER TABLE metrics +ADD COLUMN tripled integer GENERATED ALWAYS AS ((a * 3)) STORED CONSTRAINT metrics_tripled_key UNIQUE; + +ALTER TABLE metrics ALTER COLUMN doubled SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metrics ALTER COLUMN label DROP EXPRESSION; + +ALTER TABLE metrics ALTER COLUMN label SET DEFAULT 'none'; + +ALTER TABLE metrics +ADD CONSTRAINT metrics_tripled_check CHECK (tripled > 0) NOT VALID; + +ALTER TABLE metrics VALIDATE CONSTRAINT metrics_tripled_check; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS metrics_tripled_idx_pgschema_new ON metrics ((tripled + 1)) WHERE (tripled > 10); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'metrics_tripled_idx_pgschema_new'; + +DROP INDEX IF EXISTS metrics_tripled_idx; + +ALTER INDEX metrics_tripled_idx_pgschema_new RENAME TO metrics_tripled_idx; + +DROP POLICY IF EXISTS orders_big ON orders; + +ALTER TABLE orders DROP CONSTRAINT orders_total_excl; + +ALTER TABLE orders DROP COLUMN total; + +ALTER TABLE orders DROP COLUMN code; + +ALTER TABLE orders ADD COLUMN total integer GENERATED ALWAYS AS ((qty * price)) STORED; + +ALTER TABLE orders ADD COLUMN code text GENERATED ALWAYS AS (('ORD-'::text || (id)::text)) STORED; + +ALTER TABLE orders +ADD CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =); + +CREATE OR REPLACE TRIGGER orders_total_trg + AFTER UPDATE ON orders + FOR EACH ROW + WHEN (((NEW.total > 0))) + EXECUTE FUNCTION orders_audit(); + +CREATE POLICY orders_big ON orders TO PUBLIC USING (total > 100); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_code_key_pgschema_new ON orders (code); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_code_key_pgschema_new'; + +DROP INDEX IF EXISTS orders_code_key; + +ALTER INDEX orders_code_key_pgschema_new RENAME TO orders_code_key; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_lookup_idx_pgschema_new ON orders (total); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_lookup_idx_pgschema_new'; + +DROP INDEX IF EXISTS orders_lookup_idx; + +ALTER INDEX orders_lookup_idx_pgschema_new RENAME TO orders_lookup_idx; + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_total_key ON orders (total); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_total_key'; + +ALTER TABLE vt DROP COLUMN v2; + +ALTER TABLE vt DROP COLUMN s1; + +ALTER TABLE vt ADD COLUMN v2 integer; + +ALTER TABLE vt ADD COLUMN s1 integer GENERATED ALWAYS AS ((a * 2)) VIRTUAL; + +ALTER TABLE vt ALTER COLUMN v1 SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metric_refs +ADD CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES metrics (tripled) NOT VALID; + +ALTER TABLE metric_refs VALIDATE CONSTRAINT metric_refs_tripled_fkey; + +ALTER TABLE returns +ADD CONSTRAINT returns_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID; + +ALTER TABLE returns VALIDATE CONSTRAINT returns_order_code_fkey; + +ALTER TABLE returns +ADD CONSTRAINT returns_order_total_fkey FOREIGN KEY (order_total) REFERENCES orders (total) NOT VALID; + +ALTER TABLE returns VALIDATE CONSTRAINT returns_order_total_fkey; + +ALTER TABLE shipments +ADD CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID; + +ALTER TABLE shipments VALIDATE CONSTRAINT shipments_order_code_fkey; + +CREATE OR REPLACE VIEW order_labels AS + SELECT id, + 'x'::text AS label + FROM orders; + +CREATE OR REPLACE VIEW order_totals AS + SELECT id, + total + FROM orders; + +CREATE OR REPLACE VIEW big_orders AS + SELECT id + FROM order_totals + WHERE total > 100; + +GRANT SELECT ON TABLE order_totals TO app_reader; + +GRANT SELECT (id) ON TABLE big_orders TO app_reader; + +GRANT SELECT (id, total) ON TABLE orders TO app_reader; diff --git a/testdata/diff/create_table/issue_591_alter_generated_column/plan.txt b/testdata/diff/create_table/issue_591_alter_generated_column/plan.txt new file mode 100644 index 00000000..7f34b683 --- /dev/null +++ b/testdata/diff/create_table/issue_591_alter_generated_column/plan.txt @@ -0,0 +1,252 @@ +Plan: 3 to add, 9 to modify. + +Summary by type: + tables: 6 to modify + views: 3 to modify + privileges: 1 to add + column privileges: 2 to add + +Tables: + ~ metric_refs + - metric_refs_tripled_fkey (constraint) + + metric_refs_tripled_fkey (constraint) + ~ metrics + ~ doubled (column) + ~ label (column) + - tripled (column) + + tripled (column) + + metrics_tripled_check (constraint) + ~ metrics_tripled_idx (index - concurrent rebuild) + ~ orders + - code (column) + + code (column) + - total (column) + + total (column) + - orders_total_excl (constraint) + + orders_total_excl (constraint) + ~ orders_code_key (index - concurrent rebuild) + ~ orders_lookup_idx (index - concurrent rebuild) + + orders_total_key (index) + - orders_big (policy) + + orders_big (policy) + - orders_total_trg (trigger) + + orders_total_trg (trigger) + ~ returns + + returns_order_code_fkey (constraint) + + returns_order_total_fkey (constraint) + ~ shipments + - shipments_order_code_fkey (constraint) + + shipments_order_code_fkey (constraint) + ~ vt + - s1 (column) + + s1 (column) + ~ v1 (column) + - v2 (column) + + v2 (column) + +Views: + ~ big_orders + ~ order_labels + ~ order_totals + +Privileges: + + app_reader + +Column privileges: + + app_reader + + app_reader + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +DROP VIEW IF EXISTS big_orders RESTRICT; + +DROP VIEW IF EXISTS order_totals RESTRICT; + +DROP VIEW IF EXISTS order_labels RESTRICT; + +DROP TRIGGER IF EXISTS orders_total_trg ON orders; + +ALTER TABLE metric_refs DROP CONSTRAINT metric_refs_tripled_fkey; + +ALTER TABLE shipments DROP CONSTRAINT shipments_order_code_fkey; + +ALTER TABLE metrics DROP COLUMN tripled; + +ALTER TABLE metrics +ADD COLUMN tripled integer GENERATED ALWAYS AS ((a * 3)) STORED CONSTRAINT metrics_tripled_key UNIQUE; + +ALTER TABLE metrics ALTER COLUMN doubled SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metrics ALTER COLUMN label DROP EXPRESSION; + +ALTER TABLE metrics ALTER COLUMN label SET DEFAULT 'none'; + +ALTER TABLE metrics +ADD CONSTRAINT metrics_tripled_check CHECK (tripled > 0) NOT VALID; + +-- Transaction Group #2 +ALTER TABLE metrics VALIDATE CONSTRAINT metrics_tripled_check; + +-- Transaction Group #3 +CREATE INDEX CONCURRENTLY IF NOT EXISTS metrics_tripled_idx_pgschema_new ON metrics ((tripled + 1)) WHERE (tripled > 10); + +-- Transaction Group #4 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'metrics_tripled_idx_pgschema_new'; + +-- Transaction Group #5 +DROP INDEX IF EXISTS metrics_tripled_idx; + +ALTER INDEX metrics_tripled_idx_pgschema_new RENAME TO metrics_tripled_idx; + +DROP POLICY IF EXISTS orders_big ON orders; + +ALTER TABLE orders DROP CONSTRAINT orders_total_excl; + +ALTER TABLE orders DROP COLUMN total; + +ALTER TABLE orders DROP COLUMN code; + +ALTER TABLE orders ADD COLUMN total integer GENERATED ALWAYS AS ((qty * price)) STORED; + +ALTER TABLE orders ADD COLUMN code text GENERATED ALWAYS AS (('ORD-'::text || (id)::text)) STORED; + +ALTER TABLE orders +ADD CONSTRAINT orders_total_excl EXCLUDE USING btree ((total + 0) WITH =); + +CREATE OR REPLACE TRIGGER orders_total_trg + AFTER UPDATE ON orders + FOR EACH ROW + WHEN (((NEW.total > 0))) + EXECUTE FUNCTION orders_audit(); + +CREATE POLICY orders_big ON orders TO PUBLIC USING (total > 100); + +-- Transaction Group #6 +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_code_key_pgschema_new ON orders (code); + +-- Transaction Group #7 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_code_key_pgschema_new'; + +-- Transaction Group #8 +DROP INDEX IF EXISTS orders_code_key; + +ALTER INDEX orders_code_key_pgschema_new RENAME TO orders_code_key; + +-- Transaction Group #9 +CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_lookup_idx_pgschema_new ON orders (total); + +-- Transaction Group #10 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_lookup_idx_pgschema_new'; + +-- Transaction Group #11 +DROP INDEX IF EXISTS orders_lookup_idx; + +ALTER INDEX orders_lookup_idx_pgschema_new RENAME TO orders_lookup_idx; + +-- Transaction Group #12 +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS orders_total_key ON orders (total); + +-- Transaction Group #13 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'orders_total_key'; + +-- Transaction Group #14 +ALTER TABLE vt DROP COLUMN v2; + +ALTER TABLE vt DROP COLUMN s1; + +ALTER TABLE vt ADD COLUMN v2 integer; + +ALTER TABLE vt ADD COLUMN s1 integer GENERATED ALWAYS AS ((a * 2)) VIRTUAL; + +ALTER TABLE vt ALTER COLUMN v1 SET EXPRESSION AS ((a * 2)); + +ALTER TABLE metric_refs +ADD CONSTRAINT metric_refs_tripled_fkey FOREIGN KEY (tripled) REFERENCES metrics (tripled) NOT VALID; + +-- Transaction Group #15 +ALTER TABLE metric_refs VALIDATE CONSTRAINT metric_refs_tripled_fkey; + +-- Transaction Group #16 +ALTER TABLE returns +ADD CONSTRAINT returns_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID; + +-- Transaction Group #17 +ALTER TABLE returns VALIDATE CONSTRAINT returns_order_code_fkey; + +-- Transaction Group #18 +ALTER TABLE returns +ADD CONSTRAINT returns_order_total_fkey FOREIGN KEY (order_total) REFERENCES orders (total) NOT VALID; + +-- Transaction Group #19 +ALTER TABLE returns VALIDATE CONSTRAINT returns_order_total_fkey; + +-- Transaction Group #20 +ALTER TABLE shipments +ADD CONSTRAINT shipments_order_code_fkey FOREIGN KEY (order_code) REFERENCES orders (code) NOT VALID; + +-- Transaction Group #21 +ALTER TABLE shipments VALIDATE CONSTRAINT shipments_order_code_fkey; + +-- Transaction Group #22 +CREATE OR REPLACE VIEW order_labels AS + SELECT id, + 'x'::text AS label + FROM orders; + +CREATE OR REPLACE VIEW order_totals AS + SELECT id, + total + FROM orders; + +CREATE OR REPLACE VIEW big_orders AS + SELECT id + FROM order_totals + WHERE total > 100; + +GRANT SELECT ON TABLE order_totals TO app_reader; + +GRANT SELECT (id) ON TABLE big_orders TO app_reader; + +GRANT SELECT (id, total) ON TABLE orders TO app_reader; diff --git a/testdata/diff/dependency/table_fk_to_generated_column/diff.sql b/testdata/diff/dependency/table_fk_to_generated_column/diff.sql index ab07d9d7..7df1404b 100644 --- a/testdata/diff/dependency/table_fk_to_generated_column/diff.sql +++ b/testdata/diff/dependency/table_fk_to_generated_column/diff.sql @@ -15,7 +15,7 @@ $$; CREATE TABLE IF NOT EXISTS article ( id integer, title text NOT NULL, - priority integer GENERATED ALWAYS AS (public.calc_priority()) STORED, + priority integer GENERATED ALWAYS AS (calc_priority()) STORED, CONSTRAINT article_pkey PRIMARY KEY (id) );